├── .babelrc ├── .codeclimate.yml ├── .github └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── .nvmrc ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── eslint.config.mjs ├── icons ├── stutter.png ├── stutter.svg ├── stutter128.png ├── stutter16.png ├── stutter48.png ├── stutter64.png └── stutter96.png ├── manifest.json ├── package-lock.json ├── package.json ├── src-bg └── index.js ├── src-common └── stutterOptions.js ├── src-content ├── index.js ├── lib │ ├── Readability.cjs │ ├── block.js │ ├── locales.js │ ├── locales.json │ ├── parts.js │ ├── stutter.js │ ├── ui.js │ └── word.js ├── style.scss └── themes │ ├── _default.scss │ ├── _gameboy.scss │ ├── _hacktoberfest.scss │ ├── _light.scss │ ├── _night.scss │ ├── _nord.scss │ ├── _skeletor.scss │ ├── _solarized.scss │ ├── _terminal.scss │ └── _themes.scss ├── src-options ├── index.html ├── index.js └── main.scss ├── web-ext-config.cjs ├── webpack.bg.js ├── webpack.content.js └── webpack.options.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { 4 | "useBuiltIns": "usage", 5 | "corejs": 3 6 | }] 7 | ], 8 | "plugins": [ 9 | ["@babel/plugin-transform-runtime", 10 | { 11 | "regenerator": true 12 | } 13 | ] 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" # required to adjust maintainability checks 2 | checks: 3 | method-count: 4 | enabled: false 5 | method-lines: 6 | enabled: false 7 | similar-code: 8 | enabled: false 9 | plugins: 10 | scss-lint: 11 | enabled: true 12 | markdownlint: 13 | enabled: true 14 | fixme: 15 | enabled: true 16 | eslint: 17 | enabled: true 18 | channel: "eslint-7" 19 | exclude_patterns: 20 | - "**/node_modules/" 21 | - "**/vendor/" 22 | - "**/Readability.cjs" 23 | - "**/text-fragment-utils.js" 24 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '24 3 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist-bg 3 | dist-content 4 | dist-options 5 | web-ext-artifacts 6 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22.14.0 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: "lts/*" 3 | cache: yarn 4 | after_success: 5 | - yarn build 6 | - yarn postbuild-test 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NODE_MODULES ?= node_modules 2 | 3 | VERSION:=$(shell git describe --abbrev=0 --always --match v*) 4 | mkfile_path:=$(abspath $(lastword $(MAKEFILE_LIST))) 5 | current_dir:=$(notdir $(patsubst %/,%,$(dir $(mkfile_path)))) 6 | 7 | WEBEXT := $(NODE_MODULES)/.bin/web-ext 8 | 9 | help: 10 | @echo "targets:" 11 | @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ 12 | | sed -n 's/^\(.*\): \(.*\)##\(.*\)/ \1|\3/p' \ 13 | | column -t -s '|' 14 | 15 | build: ## build project 16 | npm run build 17 | 18 | package: build ## package for upload 19 | $(WEBEXT) build --overwrite-dest 20 | git archive --format zip --output "./web-ext-artifacts/$(current_dir)-$(VERSION)-src.zip" master 21 | 22 | test: ## linting tests 23 | npm run test 24 | 25 | test-webext: ## web extension tests 26 | npm run webext-test 27 | 28 | test-browser: ## launch test browser 29 | npm run extension 30 | 31 | .PHONY: help build package test test-webext test-browser 32 | 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # stutter ![status](https://travis-ci.com/jamestomasino/stutter.svg?branch=master) ![GitHub](https://img.shields.io/github/license/jamestomasino/stutter.svg) [![Maintainability](https://api.codeclimate.com/v1/badges/a4d5b54b3cf91c6a2b3e/maintainability)](https://codeclimate.com/github/jamestomasino/stutter/maintainability) 2 | 3 | 4 | 5 | Table of contents 6 | ================= 7 | 8 | 9 | * [About Stutter](#about-stutter) 10 | * [Installation](#installation) 11 | * [Usage](#usage) 12 | * [Getting Help](#getting-help) 13 | * [Contributing](#contributing) 14 | * [Research](#research) 15 | * [Privacy Policy](#privacy-policy) 16 | * [License](#license) 17 | 18 | 19 | ## About Stutter 20 | 21 | **stutter** 22 | 23 | Stutter is a [Rapid Serial Visual Presentation](https://en.wikipedia.org/wiki/Rapid_serial_visual_presentation) (RSVP) extension for modern web browsers. RSVP is a way to read faster with less eye movement. 24 | 25 | [See a Stutter demonstration here.](https://www.youtube.com/watch?v=TKgZAOQctzo) 26 | 27 | ## Installation 28 | 29 | * [![Firefox Get Extension](https://img.shields.io/badge/Firefox-Get%20Extension!-lightgrey.svg?style=popout&logo=mozilla-firefox)](https://addons.mozilla.org/en-US/firefox/addon/stutter/) 30 | * [![Chrome Get Extension](https://img.shields.io/badge/Chrome-Get%20Extension!-lightgrey.svg?style=popout&logo=google-chrome)](https://chrome.google.com/webstore/detail/stutter/fbapmaboedchhgjolcnpfgoanbfajchl) 31 | * [![Edge Get Extension](https://img.shields.io/badge/Edge-Get%20Extension!-lightgrey.svg?style=popout&logo=microsoft-edge)](https://microsoftedge.microsoft.com/addons/detail/stutter/aonlnjdopgkofbgipdnfdclfpaindajj) 32 | 33 | [![stutter demonstration](https://i.ytimg.com/vi_webp/TKgZAOQctzo/maxresdefault.webp)](https://www.youtube.com/watch?v=TKgZAOQctzo) 34 | 35 | ## Usage 36 | 37 | You can begin running Stutter in one of three ways: 38 | 39 | 1. Click on the icon in the browser to start _Stuttering_. If you have text selected, it will use this as the content to Stutter, otherwise the entire page will be used. 40 | 2. Press `Alt+R` to trigger Stutter by hotkey. 41 | 3. Select text you'd like to Stutter and then right-click and choose "Stutter Selection". 42 | 43 | When Stutter is running, you can use the following hotkeys for control: 44 | 45 | - `Alt+R` - Restart Stutter 46 | - `Alt+P` - Pause/Resume 47 | - `Alt+Left` - Skip backwards 48 | - `Alt+Right` - Skip forwards 49 | - `Alt+Up` - Increase WPM by 50 50 | - `Alt+Down` - Decrease WPM by 50 51 | - `Esc` - Close Stutter 52 | 53 | You can reposition the Stutter interface on the screen by dragging the handle on the left hand side. Stutter will remember its position in the future. 54 | 55 | Many other timing options and theming are available inside the full settings panel. Click on the gear icon on the left while Stutter is running to change these settings. **Note:** You must have allowed the storage permission in order to change these default settings. 56 | 57 | ## Getting Help 58 | 59 | You can leave feedback using [GitHub issues](https://github.com/jamestomasino/stutter/issues). If you would like to discuss problems or features with me directly, you can visit the [#stutter IRC channel on Libera.Chat](https://kiwiirc.com/nextclient/#irc://irc.libera.chat/#stutter). 60 | 61 | ## Contributing 62 | 63 | This is an open source project and we welcome contributions. See the [Wiki](https://github.com/jamestomasino/stutter/wiki) for ways to contribute: 64 | 65 | - [Install from Source](https://github.com/jamestomasino/stutter/wiki/Install) 66 | - [Themes](https://github.com/jamestomasino/stutter/wiki/Themes) 67 | - [Localization](https://github.com/jamestomasino/stutter/wiki/Locale) 68 | - [Third Party Libraries](https://github.com/jamestomasino/stutter/wiki/ThirdParty) 69 | - [Browser Permissions](https://github.com/jamestomasino/stutter/wiki/Permissions) 70 | 71 | Pull requests are welcome. For major changes, please open an issue first to 72 | discuss what you would like to change. 73 | 74 | ## Research 75 | 76 | [Read some of the research](https://github.com/jamestomasino/stutter/wiki/Research) that influences Stutter. 77 | 78 | ## Privacy Policy 79 | 80 | This browser extension collects no user data. Nothing about your usage is stored or transferred to any server. It can be used offline. 81 | 82 | ## License 83 | 84 | [GPL3](LICENSE) 85 | 86 | Mozilla's Readability library - http://www.apache.org/licenses/LICENSE-2.0 87 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { fileURLToPath } from "node:url"; 3 | import js from "@eslint/js"; 4 | import { FlatCompat } from "@eslint/eslintrc"; 5 | 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = path.dirname(__filename); 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | recommendedConfig: js.configs.recommended, 11 | allConfig: js.configs.all 12 | }); 13 | 14 | export default [...compat.extends("standard"), { 15 | rules: { 16 | "comma-dangle": ["error", { 17 | arrays: "ignore", 18 | objects: "ignore", 19 | imports: "ignore", 20 | exports: "ignore", 21 | functions: "ignore", 22 | }], 23 | }, 24 | }]; 25 | -------------------------------------------------------------------------------- /icons/stutter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamestomasino/stutter/a2b47a489d33d910398269a6d044c6e7516103ed/icons/stutter.png -------------------------------------------------------------------------------- /icons/stutter.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 16 | 18 | 22 | 26 | 27 | 29 | 33 | 37 | 38 | 41 | 51 | 52 | 60 | 68 | 69 | 71 | 72 | 74 | image/svg+xml 75 | 77 | 78 | 79 | 80 | 81 | 93 | 100 | 104 | 105 | -------------------------------------------------------------------------------- /icons/stutter128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamestomasino/stutter/a2b47a489d33d910398269a6d044c6e7516103ed/icons/stutter128.png -------------------------------------------------------------------------------- /icons/stutter16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamestomasino/stutter/a2b47a489d33d910398269a6d044c6e7516103ed/icons/stutter16.png -------------------------------------------------------------------------------- /icons/stutter48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamestomasino/stutter/a2b47a489d33d910398269a6d044c6e7516103ed/icons/stutter48.png -------------------------------------------------------------------------------- /icons/stutter64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamestomasino/stutter/a2b47a489d33d910398269a6d044c6e7516103ed/icons/stutter64.png -------------------------------------------------------------------------------- /icons/stutter96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamestomasino/stutter/a2b47a489d33d910398269a6d044c6e7516103ed/icons/stutter96.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "James Tomasino", 3 | "manifest_version": 3, 4 | "name": "stutter", 5 | "short_name": "stutter", 6 | "description": "RSVP for browsers", 7 | "version": "2.0.1", 8 | "homepage_url": "https://github.com/jamestomasino/stutter", 9 | "offline_enabled": true, 10 | "icons": { 11 | "16": "icons/stutter16.png", 12 | "48": "icons/stutter48.png", 13 | "64": "icons/stutter64.png", 14 | "96": "icons/stutter96.png", 15 | "128": "icons/stutter128.png" 16 | }, 17 | "commands": { 18 | "_execute_action": { 19 | "suggested_key": { 20 | "windows": "Alt+R", 21 | "mac": "Alt+R", 22 | "chromeos": "Alt+R", 23 | "linux": "Alt+R" 24 | } 25 | } 26 | }, 27 | "action": { 28 | "default_title": "stutter" 29 | }, 30 | "background": { 31 | "service_worker": "dist-bg/index.js", 32 | "scripts": ["dist-bg/index.js"] 33 | }, 34 | "browser_specific_settings": { 35 | "gecko": { 36 | "id": "{8cc45662-d58a-4a06-bf7b-4fcdf1d54b8d}" 37 | } 38 | }, 39 | "permissions": [ 40 | "contextMenus", 41 | "activeTab", 42 | "storage", 43 | "scripting" 44 | ], 45 | "options_page": "dist-options/index.html", 46 | "options_ui": { 47 | "page": "dist-options/index.html", 48 | "open_in_tab":true 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stutter", 3 | "version": "2.0.1", 4 | "description": "RSVP for browsers", 5 | "main": "index.js", 6 | "repository": "https://github.com/jamestomasino/stutter", 7 | "author": "James Tomasino ", 8 | "license": "GPL-3.0", 9 | "private": false, 10 | "scripts": { 11 | "extension": "web-ext run --verbose", 12 | "package": "npm run build && web-ext build --overwrite-dest && git archive --format zip --output \"./web-ext-artifacts/$(basename \"$(pwd)\")-\"$(awk -F\": \" '/\"version\"/ {print $2}' manifest.json | sed 's/^\"//' | sed 's/\".*//')\"-src.zip\" master", 13 | "build": "webpack --config 'webpack.bg.js' && webpack --config 'webpack.content.js' && webpack --config 'webpack.options.js'", 14 | "test": "eslint \"./src-*/*.js\"", 15 | "webext-test": "web-ext lint" 16 | }, 17 | "browserslist": "> 0.25%, not dead", 18 | "devDependencies": { 19 | "@babel/core": "^7.26.9", 20 | "@babel/plugin-transform-runtime": "^7.26.9", 21 | "@babel/preset-env": "^7.26.9", 22 | "@babel/runtime": "^7.26.9", 23 | "@babel/runtime-corejs3": "^7.26.9", 24 | "@eslint/eslintrc": "^3.2.0", 25 | "@eslint/js": "^9.20.0", 26 | "babel-loader": "^9.2.1", 27 | "core-js": "3", 28 | "css-loader": "^7.1.2", 29 | "eslint": "^9.20.1", 30 | "eslint-config-standard": "^17.1.0", 31 | "eslint-plugin-import": "^2.31.0", 32 | "eslint-plugin-n": "^17.15.1", 33 | "eslint-plugin-node": "^11.1.0", 34 | "eslint-plugin-promise": "^7.2.1", 35 | "eslint-plugin-standard": "^5.0.0", 36 | "eslint-webpack-plugin": "^4.2.0", 37 | "html-to-text": "^9.0.5", 38 | "html-webpack-plugin": "^5.6.3", 39 | "mini-css-extract-plugin": "^2.9.2", 40 | "sass": "^1.85.0", 41 | "sass-loader": "^16.0.5", 42 | "style-loader": "^4.0.0", 43 | "web-ext": "^8.4.0", 44 | "webextension-polyfill": "^0.12.0", 45 | "webpack": "^5.98.0", 46 | "webpack-cli": "^6.0.1" 47 | }, 48 | "dependencies": {} 49 | } 50 | -------------------------------------------------------------------------------- /src-bg/index.js: -------------------------------------------------------------------------------- 1 | /* global browser */ 2 | if (typeof browser === 'undefined') { 3 | // Chrome does not support the browser namespace yet. 4 | globalThis.browser = chrome /* eslint-disable-line no-undef */ 5 | } 6 | 7 | try { 8 | browser.action.onClicked.addListener(async (tab) => { 9 | await browser.scripting.executeScript({ 10 | target: { tabId: tab.id, allFrames: true }, 11 | files: ['/dist-content/index.js'], 12 | }) 13 | const response = await browser.tabs.sendMessage(tab.id, { 14 | functiontoInvoke: 'stutterFullPage' 15 | }) 16 | console.log(response) 17 | }) 18 | 19 | browser.runtime.onMessage.addListener(function (request, sender, sendResponse) { 20 | switch (request.functiontoInvoke) { 21 | case 'openSettings': 22 | browser.runtime.openOptionsPage() 23 | break 24 | } 25 | }) 26 | 27 | browser.contextMenus.create({ 28 | id: 'stutterSelection', 29 | title: 'Stutter Selection', 30 | contexts: ['selection'] 31 | }) 32 | 33 | browser.contextMenus.onClicked.addListener(async (info, tab) => { 34 | const { menuItemId } = info 35 | 36 | if (menuItemId === 'stutterSelection') { 37 | await browser.scripting.executeScript({ 38 | target: { tabId: tab.id, allFrames: true }, 39 | files: ['/dist-content/index.js'], 40 | }) 41 | const response = await browser.tabs.sendMessage(tab.id, { 42 | functiontoInvoke: 'stutterSelectedText', 43 | selectedText: info.selectionText 44 | }) 45 | console.log(response) 46 | } 47 | }) 48 | 49 | browser.commands.onCommand.addListener((command) => { 50 | console.log(`Command: ${command}`) 51 | }) 52 | } catch (e) { 53 | console.warn(e) 54 | } 55 | -------------------------------------------------------------------------------- /src-common/stutterOptions.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | const browser = require('webextension-polyfill') 3 | 4 | const defaults = { 5 | wpm: 400, 6 | slowStartCount: 5, 7 | sentenceDelay: 2.5, 8 | otherPuncDelay: 1.5, 9 | shortWordDelay: 1.3, 10 | longWordDelay: 1.4, 11 | numericDelay: 1.8, 12 | theme: 'default', 13 | pos: 0.5, 14 | maxWordLength: 13, 15 | skipCount: 10, 16 | showFlankers: false, 17 | keybindPauseModifier: 'Alt', 18 | keybindPauseKey: 'p', 19 | keybindRestartModifier: 'Alt', 20 | keybindRestartKey: 'r', 21 | keybindPreviousModifier: 'Alt', 22 | keybindPreviousKey: 'ArrowLeft', 23 | keybindForwardModifier: 'Alt', 24 | keybindForwardKey: 'ArrowRight', 25 | keybindSpeedUpModifier: 'Alt', 26 | keybindSpeedUpKey: 'ArrowUp', 27 | keybindSpeedDownModifier: 'Alt', 28 | keybindSpeedDownKey: 'ArrowDown', 29 | keybindCloseModifier: '', 30 | keybindCloseKey: 'Escape' 31 | } 32 | 33 | let instance = null 34 | 35 | export default class StutterOptions extends EventEmitter { 36 | constructor () { 37 | super() 38 | 39 | if (instance) { 40 | return instance 41 | } else { 42 | instance = this 43 | 44 | Object.keys(defaults).forEach(setting => { 45 | this['_' + setting] = defaults[setting] 46 | }) 47 | 48 | this.checkSaved() 49 | browser.runtime.onMessage.addListener(message => { this.onMessage(message) }) 50 | } 51 | } 52 | 53 | static get UPDATE () { return 'STUTTER_OPTIONS_UPDATE' } 54 | static get CHECK_SAVED () { return 'STUTTER_OPTIONS_CHECKSAVED' } 55 | 56 | checkSaved () { 57 | browser.storage.sync.get('stutterOptions').then(result => { 58 | if (result.stutterOptions) { 59 | this.settings = result.stutterOptions 60 | } else { 61 | // Porting to sync. If the old local is set, copy it over 62 | // to sync, then remove 63 | browser.storage.local.get('stutterOptions').then(result => { 64 | if (result.stutterOptions) { 65 | this.settings = result.stutterOptions 66 | browser.storage.local.clear() 67 | } 68 | }) 69 | } 70 | }) 71 | this.emit(StutterOptions.CHECK_SAVED) 72 | } 73 | 74 | onMessage (request) { 75 | switch (request.functiontoInvoke) { 76 | case 'stutterOptionsUpdate': 77 | this.checkSaved() 78 | break 79 | default: 80 | break 81 | } 82 | } 83 | 84 | update () { 85 | // Save settings to localstorage 86 | this.saveSettings() 87 | 88 | // Inform direct listeners 89 | this.emit(StutterOptions.UPDATE) 90 | 91 | // Inform the other tabs StutterOptions instances 92 | if (browser && browser.tabs && browser.tabs.query) { 93 | browser.tabs.query({}).then(tabs => { 94 | for (const tab of tabs) { 95 | browser.tabs.sendMessage(tab.id, { 96 | functiontoInvoke: 'stutterOptionsUpdate' 97 | }).then(() => {}).catch(() => {}) 98 | } 99 | }).catch(() => {}) 100 | } 101 | } 102 | 103 | reset () { 104 | this.settings = defaults 105 | } 106 | 107 | saveSettings () { 108 | browser.storage.sync.set({ 109 | stutterOptions: this.settings 110 | }) 111 | } 112 | 113 | get settings () { 114 | const returnObj = {} 115 | Object.keys(defaults).forEach(setting => { 116 | returnObj[setting] = this['_' + setting] 117 | }) 118 | return returnObj 119 | } 120 | 121 | set settings (val) { 122 | let invalidate = false 123 | Object.keys(defaults).forEach(setting => { 124 | if (val && Object.prototype.hasOwnProperty.call(val, setting) && this['_' + setting] !== val[setting]) { 125 | this['_' + setting] = val[setting] 126 | invalidate = true 127 | } 128 | }) 129 | if (invalidate) this.update() 130 | } 131 | 132 | getProp (prop) { 133 | return this['_' + prop] 134 | } 135 | 136 | setProp (prop, val) { 137 | switch (prop) { 138 | case 'wpm': 139 | val = this.numericContain(100, 1800, val) 140 | break 141 | case 'sentenceDelay': 142 | case 'otherPuncDelay': 143 | case 'shortWordDelay': 144 | case 'numericDelay': 145 | case 'slowStartCount': 146 | val = this.numericContain(1, 10, val) 147 | break 148 | case 'pos': 149 | val = this.numericContain(0.02, 0.9, val) 150 | break 151 | case 'maxWordLength': 152 | val = parseInt(this.numericContain(5, 50, val), 10) 153 | break 154 | case 'skipCount': 155 | val = parseInt(this.numericContain(0, 100, val), 10) 156 | break 157 | case 'showFlankers': 158 | val = !!val 159 | break 160 | } 161 | if (Object.prototype.hasOwnProperty.call(this, '_' + prop) && this['_' + prop] !== val) { 162 | this['_' + prop] = val 163 | this.update() 164 | } 165 | } 166 | 167 | numericContain (low, high, val) { 168 | val = Number(val) 169 | if (isNaN(val)) return 170 | val = Math.max(low, val) 171 | val = Math.min(high, val) 172 | return val 173 | } 174 | 175 | get delay () { return 1 / (this._wpm / 60) * 1000 } 176 | } 177 | -------------------------------------------------------------------------------- /src-content/index.js: -------------------------------------------------------------------------------- 1 | import Readability from './lib/Readability.cjs' 2 | import Stutter from './lib/stutter' 3 | import UI from './lib/ui' 4 | const { convert } = require('html-to-text') 5 | let stutter 6 | let ui 7 | 8 | function playStutter (text) { 9 | if (stutter) { 10 | stutter.destroy() 11 | } 12 | 13 | stutter = new Stutter(ui) 14 | stutter.setText(text) 15 | stutter.play() 16 | } 17 | 18 | function onMessage (request) { 19 | let selection 20 | switch (request.functiontoInvoke) { 21 | case 'stutterSelectedText': 22 | // pass selection to Stutter 23 | playStutter(request.selectedText) 24 | break 25 | case 'stutterFullPage': 26 | selection = getSelectionText() 27 | if (selection) { 28 | // console.log('Selection:', selection) 29 | playStutter(selection) 30 | } else { 31 | // close document switch Readability is destructive 32 | const documentClone = document.cloneNode(true) 33 | const article = new Readability(documentClone).parse() 34 | const pureText = convert(article.content, { 35 | selectors: [ 36 | { 37 | selector: 'a', 38 | options: { 39 | ignoreHref: true, 40 | noAnchorUrl: true, 41 | noLinkBrackets: true 42 | } 43 | }, 44 | { 45 | selector: 'img', 46 | format: 'skip' 47 | } 48 | ], 49 | wordwrap: false 50 | }) 51 | // Pass article content to Stutter 52 | playStutter(pureText) 53 | } 54 | break 55 | default: 56 | break 57 | } 58 | } 59 | 60 | function getSelectionText () { 61 | let text = '' 62 | const activeEl = document.activeElement 63 | const activeElTagName = activeEl ? activeEl.tagName.toLowerCase() : null 64 | if (activeElTagName === 'textarea') { 65 | text = activeEl.value.slice(activeEl.selectionStart, activeEl.selectionEnd) 66 | } else if (window.getSelection) { 67 | text = window.getSelection().toString() 68 | } 69 | return text 70 | } 71 | /* This check avoids duplicating the DOM and listeners in case we 72 | * are running stutter more than once. The first call to inject 73 | * this code from the background script will enter this condition 74 | * and create everything needed on the page. Subsequent calls to 75 | * inject will hit this condition and fail, avoiding double UI 76 | * 77 | * Unfortunately this does not stop CSS from being injected twice, 78 | * or the actual JS content from being injected multiple times. It 79 | * would be better to not inject more than once at all, but the 80 | * background script has no knowledge of whether the tab has loaded 81 | * stutter before or not on any given page. 82 | * 83 | * Consider this solution the "least bad" for now. 84 | */ 85 | if (!UI.INIT && !window.__stutter) { 86 | window.__stutter = true 87 | const browser = require('webextension-polyfill') 88 | ui = new UI() 89 | browser.runtime.onMessage.addListener(onMessage) 90 | } 91 | -------------------------------------------------------------------------------- /src-content/lib/Readability.cjs: -------------------------------------------------------------------------------- 1 | /*eslint-disable*/ 2 | /* 3 | * Copyright (c) 2010 Arc90 Inc 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | /* 19 | * This code is heavily based on Arc90's readability.js (1.7.1) script 20 | * available at: http://code.google.com/p/arc90labs-readability 21 | */ 22 | 23 | /** 24 | * Public constructor. 25 | * @param {HTMLDocument} doc The document to parse. 26 | * @param {Object} options The options object. 27 | */ 28 | function Readability(doc, options) { 29 | // In some older versions, people passed a URI as the first argument. Cope: 30 | if (options && options.documentElement) { 31 | doc = options; 32 | options = arguments[2]; 33 | } else if (!doc || !doc.documentElement) { 34 | throw new Error("First argument to Readability constructor should be a document object."); 35 | } 36 | options = options || {}; 37 | 38 | this._doc = doc; 39 | this._docJSDOMParser = this._doc.firstChild.__JSDOMParser__; 40 | this._articleTitle = null; 41 | this._articleByline = null; 42 | this._articleDir = null; 43 | this._articleSiteName = null; 44 | this._attempts = []; 45 | 46 | // Configurable options 47 | this._debug = !!options.debug; 48 | this._maxElemsToParse = options.maxElemsToParse || this.DEFAULT_MAX_ELEMS_TO_PARSE; 49 | this._nbTopCandidates = options.nbTopCandidates || this.DEFAULT_N_TOP_CANDIDATES; 50 | this._charThreshold = options.charThreshold || this.DEFAULT_CHAR_THRESHOLD; 51 | this._classesToPreserve = this.CLASSES_TO_PRESERVE.concat(options.classesToPreserve || []); 52 | this._keepClasses = !!options.keepClasses; 53 | this._serializer = options.serializer || function(el) { 54 | return el.innerHTML; 55 | }; 56 | this._disableJSONLD = !!options.disableJSONLD; 57 | 58 | // Start with all flags set 59 | this._flags = this.FLAG_STRIP_UNLIKELYS | 60 | this.FLAG_WEIGHT_CLASSES | 61 | this.FLAG_CLEAN_CONDITIONALLY; 62 | 63 | 64 | // Control whether log messages are sent to the console 65 | if (this._debug) { 66 | let logNode = function(node) { 67 | if (node.nodeType == node.TEXT_NODE) { 68 | return `${node.nodeName} ("${node.textContent}")`; 69 | } 70 | let attrPairs = Array.from(node.attributes || [], function(attr) { 71 | return `${attr.name}="${attr.value}"`; 72 | }).join(" "); 73 | return `<${node.localName} ${attrPairs}>`; 74 | }; 75 | this.log = function () { 76 | if (typeof dump !== "undefined") { 77 | var msg = Array.prototype.map.call(arguments, function(x) { 78 | return (x && x.nodeName) ? logNode(x) : x; 79 | }).join(" "); 80 | dump("Reader: (Readability) " + msg + "\n"); 81 | } else if (typeof console !== "undefined") { 82 | let args = Array.from(arguments, arg => { 83 | if (arg && arg.nodeType == this.ELEMENT_NODE) { 84 | return logNode(arg); 85 | } 86 | return arg; 87 | }); 88 | args.unshift("Reader: (Readability)"); 89 | console.log.apply(console, args); 90 | } 91 | }; 92 | } else { 93 | this.log = function () {}; 94 | } 95 | } 96 | 97 | Readability.prototype = { 98 | FLAG_STRIP_UNLIKELYS: 0x1, 99 | FLAG_WEIGHT_CLASSES: 0x2, 100 | FLAG_CLEAN_CONDITIONALLY: 0x4, 101 | 102 | // https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType 103 | ELEMENT_NODE: 1, 104 | TEXT_NODE: 3, 105 | 106 | // Max number of nodes supported by this parser. Default: 0 (no limit) 107 | DEFAULT_MAX_ELEMS_TO_PARSE: 0, 108 | 109 | // The number of top candidates to consider when analysing how 110 | // tight the competition is among candidates. 111 | DEFAULT_N_TOP_CANDIDATES: 5, 112 | 113 | // Element tags to score by default. 114 | DEFAULT_TAGS_TO_SCORE: "section,h2,h3,h4,h5,h6,p,td,pre".toUpperCase().split(","), 115 | 116 | // The default number of chars an article must have in order to return a result 117 | DEFAULT_CHAR_THRESHOLD: 500, 118 | 119 | // All of the regular expressions in use within readability. 120 | // Defined up here so we don't instantiate them repeatedly in loops. 121 | REGEXPS: { 122 | // NOTE: These two regular expressions are duplicated in 123 | // Readability-readerable.js. Please keep both copies in sync. 124 | unlikelyCandidates: /-ad-|ai2html|banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote/i, 125 | okMaybeItsACandidate: /and|article|body|column|content|main|shadow/i, 126 | 127 | positive: /article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story/i, 128 | negative: /-ad-|hidden|^hid$| hid$| hid |^hid |banner|combx|comment|com-|contact|foot|footer|footnote|gdpr|masthead|media|meta|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|tool|widget/i, 129 | extraneous: /print|archive|comment|discuss|e[\-]?mail|share|reply|all|login|sign|single|utility/i, 130 | byline: /byline|author|dateline|writtenby|p-author/i, 131 | replaceFonts: /<(\/?)font[^>]*>/gi, 132 | normalize: /\s{2,}/g, 133 | videos: /\/\/(www\.)?((dailymotion|youtube|youtube-nocookie|player\.vimeo|v\.qq)\.com|(archive|upload\.wikimedia)\.org|player\.twitch\.tv)/i, 134 | shareElements: /(\b|_)(share|sharedaddy)(\b|_)/i, 135 | nextLink: /(next|weiter|continue|>([^\|]|$)|»([^\|]|$))/i, 136 | prevLink: /(prev|earl|old|new|<|«)/i, 137 | tokenize: /\W+/g, 138 | whitespace: /^\s*$/, 139 | hasContent: /\S$/, 140 | hashUrl: /^#.+/, 141 | srcsetUrl: /(\S+)(\s+[\d.]+[xw])?(\s*(?:,|$))/g, 142 | b64DataUrl: /^data:\s*([^\s;,]+)\s*;\s*base64\s*,/i, 143 | // See: https://schema.org/Article 144 | jsonLdArticleTypes: /^Article|AdvertiserContentArticle|NewsArticle|AnalysisNewsArticle|AskPublicNewsArticle|BackgroundNewsArticle|OpinionNewsArticle|ReportageNewsArticle|ReviewNewsArticle|Report|SatiricalArticle|ScholarlyArticle|MedicalScholarlyArticle|SocialMediaPosting|BlogPosting|LiveBlogPosting|DiscussionForumPosting|TechArticle|APIReference$/ 145 | }, 146 | 147 | UNLIKELY_ROLES: [ "menu", "menubar", "complementary", "navigation", "alert", "alertdialog", "dialog" ], 148 | 149 | DIV_TO_P_ELEMS: new Set([ "BLOCKQUOTE", "DL", "DIV", "IMG", "OL", "P", "PRE", "TABLE", "UL" ]), 150 | 151 | ALTER_TO_DIV_EXCEPTIONS: ["DIV", "ARTICLE", "SECTION", "P"], 152 | 153 | PRESENTATIONAL_ATTRIBUTES: [ "align", "background", "bgcolor", "border", "cellpadding", "cellspacing", "frame", "hspace", "rules", "style", "valign", "vspace" ], 154 | 155 | DEPRECATED_SIZE_ATTRIBUTE_ELEMS: [ "TABLE", "TH", "TD", "HR", "PRE" ], 156 | 157 | // The commented out elements qualify as phrasing content but tend to be 158 | // removed by readability when put into paragraphs, so we ignore them here. 159 | PHRASING_ELEMS: [ 160 | // "CANVAS", "IFRAME", "SVG", "VIDEO", 161 | "ABBR", "AUDIO", "B", "BDO", "BR", "BUTTON", "CITE", "CODE", "DATA", 162 | "DATALIST", "DFN", "EM", "EMBED", "I", "IMG", "INPUT", "KBD", "LABEL", 163 | "MARK", "MATH", "METER", "NOSCRIPT", "OBJECT", "OUTPUT", "PROGRESS", "Q", 164 | "RUBY", "SAMP", "SCRIPT", "SELECT", "SMALL", "SPAN", "STRONG", "SUB", 165 | "SUP", "TEXTAREA", "TIME", "VAR", "WBR" 166 | ], 167 | 168 | // These are the classes that readability sets itself. 169 | CLASSES_TO_PRESERVE: [ "page" ], 170 | 171 | // These are the list of HTML entities that need to be escaped. 172 | HTML_ESCAPE_MAP: { 173 | "lt": "<", 174 | "gt": ">", 175 | "amp": "&", 176 | "quot": '"', 177 | "apos": "'", 178 | }, 179 | 180 | /** 181 | * Run any post-process modifications to article content as necessary. 182 | * 183 | * @param Element 184 | * @return void 185 | **/ 186 | _postProcessContent: function(articleContent) { 187 | // Readability cannot open relative uris so we convert them to absolute uris. 188 | this._fixRelativeUris(articleContent); 189 | 190 | this._simplifyNestedElements(articleContent); 191 | 192 | if (!this._keepClasses) { 193 | // Remove classes. 194 | this._cleanClasses(articleContent); 195 | } 196 | }, 197 | 198 | /** 199 | * Iterates over a NodeList, calls `filterFn` for each node and removes node 200 | * if function returned `true`. 201 | * 202 | * If function is not passed, removes all the nodes in node list. 203 | * 204 | * @param NodeList nodeList The nodes to operate on 205 | * @param Function filterFn the function to use as a filter 206 | * @return void 207 | */ 208 | _removeNodes: function(nodeList, filterFn) { 209 | // Avoid ever operating on live node lists. 210 | if (this._docJSDOMParser && nodeList._isLiveNodeList) { 211 | throw new Error("Do not pass live node lists to _removeNodes"); 212 | } 213 | for (var i = nodeList.length - 1; i >= 0; i--) { 214 | var node = nodeList[i]; 215 | var parentNode = node.parentNode; 216 | if (parentNode) { 217 | if (!filterFn || filterFn.call(this, node, i, nodeList)) { 218 | parentNode.removeChild(node); 219 | } 220 | } 221 | } 222 | }, 223 | 224 | /** 225 | * Iterates over a NodeList, and calls _setNodeTag for each node. 226 | * 227 | * @param NodeList nodeList The nodes to operate on 228 | * @param String newTagName the new tag name to use 229 | * @return void 230 | */ 231 | _replaceNodeTags: function(nodeList, newTagName) { 232 | // Avoid ever operating on live node lists. 233 | if (this._docJSDOMParser && nodeList._isLiveNodeList) { 234 | throw new Error("Do not pass live node lists to _replaceNodeTags"); 235 | } 236 | for (const node of nodeList) { 237 | this._setNodeTag(node, newTagName); 238 | } 239 | }, 240 | 241 | /** 242 | * Iterate over a NodeList, which doesn't natively fully implement the Array 243 | * interface. 244 | * 245 | * For convenience, the current object context is applied to the provided 246 | * iterate function. 247 | * 248 | * @param NodeList nodeList The NodeList. 249 | * @param Function fn The iterate function. 250 | * @return void 251 | */ 252 | _forEachNode: function(nodeList, fn) { 253 | Array.prototype.forEach.call(nodeList, fn, this); 254 | }, 255 | 256 | /** 257 | * Iterate over a NodeList, and return the first node that passes 258 | * the supplied test function 259 | * 260 | * For convenience, the current object context is applied to the provided 261 | * test function. 262 | * 263 | * @param NodeList nodeList The NodeList. 264 | * @param Function fn The test function. 265 | * @return void 266 | */ 267 | _findNode: function(nodeList, fn) { 268 | return Array.prototype.find.call(nodeList, fn, this); 269 | }, 270 | 271 | /** 272 | * Iterate over a NodeList, return true if any of the provided iterate 273 | * function calls returns true, false otherwise. 274 | * 275 | * For convenience, the current object context is applied to the 276 | * provided iterate function. 277 | * 278 | * @param NodeList nodeList The NodeList. 279 | * @param Function fn The iterate function. 280 | * @return Boolean 281 | */ 282 | _someNode: function(nodeList, fn) { 283 | return Array.prototype.some.call(nodeList, fn, this); 284 | }, 285 | 286 | /** 287 | * Iterate over a NodeList, return true if all of the provided iterate 288 | * function calls return true, false otherwise. 289 | * 290 | * For convenience, the current object context is applied to the 291 | * provided iterate function. 292 | * 293 | * @param NodeList nodeList The NodeList. 294 | * @param Function fn The iterate function. 295 | * @return Boolean 296 | */ 297 | _everyNode: function(nodeList, fn) { 298 | return Array.prototype.every.call(nodeList, fn, this); 299 | }, 300 | 301 | /** 302 | * Concat all nodelists passed as arguments. 303 | * 304 | * @return ...NodeList 305 | * @return Array 306 | */ 307 | _concatNodeLists: function() { 308 | var slice = Array.prototype.slice; 309 | var args = slice.call(arguments); 310 | var nodeLists = args.map(function(list) { 311 | return slice.call(list); 312 | }); 313 | return Array.prototype.concat.apply([], nodeLists); 314 | }, 315 | 316 | _getAllNodesWithTag: function(node, tagNames) { 317 | if (node.querySelectorAll) { 318 | return node.querySelectorAll(tagNames.join(",")); 319 | } 320 | return [].concat.apply([], tagNames.map(function(tag) { 321 | var collection = node.getElementsByTagName(tag); 322 | return Array.isArray(collection) ? collection : Array.from(collection); 323 | })); 324 | }, 325 | 326 | /** 327 | * Removes the class="" attribute from every element in the given 328 | * subtree, except those that match CLASSES_TO_PRESERVE and 329 | * the classesToPreserve array from the options object. 330 | * 331 | * @param Element 332 | * @return void 333 | */ 334 | _cleanClasses: function(node) { 335 | var classesToPreserve = this._classesToPreserve; 336 | var className = (node.getAttribute("class") || "") 337 | .split(/\s+/) 338 | .filter(function(cls) { 339 | return classesToPreserve.indexOf(cls) != -1; 340 | }) 341 | .join(" "); 342 | 343 | if (className) { 344 | node.setAttribute("class", className); 345 | } else { 346 | node.removeAttribute("class"); 347 | } 348 | 349 | for (node = node.firstElementChild; node; node = node.nextElementSibling) { 350 | this._cleanClasses(node); 351 | } 352 | }, 353 | 354 | /** 355 | * Converts each and uri in the given element to an absolute URI, 356 | * ignoring #ref URIs. 357 | * 358 | * @param Element 359 | * @return void 360 | */ 361 | _fixRelativeUris: function(articleContent) { 362 | var baseURI = this._doc.baseURI; 363 | var documentURI = this._doc.documentURI; 364 | function toAbsoluteURI(uri) { 365 | // Leave hash links alone if the base URI matches the document URI: 366 | if (baseURI == documentURI && uri.charAt(0) == "#") { 367 | return uri; 368 | } 369 | 370 | // Otherwise, resolve against base URI: 371 | try { 372 | return new URL(uri, baseURI).href; 373 | } catch (ex) { 374 | // Something went wrong, just return the original: 375 | } 376 | return uri; 377 | } 378 | 379 | var links = this._getAllNodesWithTag(articleContent, ["a"]); 380 | this._forEachNode(links, function(link) { 381 | var href = link.getAttribute("href"); 382 | if (href) { 383 | // Remove links with javascript: URIs, since 384 | // they won't work after scripts have been removed from the page. 385 | if (href.startsWith("javascript:") || href.startsWith("vbscript:") || href.startsWith("data:")) { 386 | // if the link only contains simple text content, it can be converted to a text node 387 | if (link.childNodes.length === 1 && link.childNodes[0].nodeType === this.TEXT_NODE) { 388 | var text = this._doc.createTextNode(link.textContent); 389 | link.parentNode.replaceChild(text, link); 390 | } else { 391 | // if the link has multiple children, they should all be preserved 392 | var container = this._doc.createElement("span"); 393 | while (link.childNodes.length > 0) { 394 | container.appendChild(link.childNodes[0]); 395 | } 396 | link.parentNode.replaceChild(container, link); 397 | } 398 | } else { 399 | link.setAttribute("href", toAbsoluteURI(href)); 400 | } 401 | } 402 | }); 403 | 404 | var medias = this._getAllNodesWithTag(articleContent, [ 405 | "img", "picture", "figure", "video", "audio", "source" 406 | ]); 407 | 408 | this._forEachNode(medias, function(media) { 409 | var src = media.getAttribute("src"); 410 | var poster = media.getAttribute("poster"); 411 | var srcset = media.getAttribute("srcset"); 412 | 413 | if (src) { 414 | media.setAttribute("src", toAbsoluteURI(src)); 415 | } 416 | 417 | if (poster) { 418 | media.setAttribute("poster", toAbsoluteURI(poster)); 419 | } 420 | 421 | if (srcset) { 422 | var newSrcset = srcset.replace(this.REGEXPS.srcsetUrl, function(_, p1, p2, p3) { 423 | return toAbsoluteURI(p1) + (p2 || "") + p3; 424 | }); 425 | 426 | media.setAttribute("srcset", newSrcset); 427 | } 428 | }); 429 | }, 430 | 431 | _simplifyNestedElements: function(articleContent) { 432 | var node = articleContent; 433 | 434 | while (node) { 435 | if (node.parentNode && ["DIV", "SECTION"].includes(node.tagName) && !(node.id && node.id.startsWith("readability"))) { 436 | if (this._isElementWithoutContent(node)) { 437 | node = this._removeAndGetNext(node); 438 | continue; 439 | } else if (this._hasSingleTagInsideElement(node, "DIV") || this._hasSingleTagInsideElement(node, "SECTION")) { 440 | var child = node.children[0]; 441 | for (var i = 0; i < node.attributes.length; i++) { 442 | child.setAttribute(node.attributes[i].name, node.attributes[i].value); 443 | } 444 | node.parentNode.replaceChild(child, node); 445 | node = child; 446 | continue; 447 | } 448 | } 449 | 450 | node = this._getNextNode(node); 451 | } 452 | }, 453 | 454 | /** 455 | * Get the article title as an H1. 456 | * 457 | * @return string 458 | **/ 459 | _getArticleTitle: function() { 460 | var doc = this._doc; 461 | var curTitle = ""; 462 | var origTitle = ""; 463 | 464 | try { 465 | curTitle = origTitle = doc.title.trim(); 466 | 467 | // If they had an element with id "title" in their HTML 468 | if (typeof curTitle !== "string") 469 | curTitle = origTitle = this._getInnerText(doc.getElementsByTagName("title")[0]); 470 | } catch (e) {/* ignore exceptions setting the title. */} 471 | 472 | var titleHadHierarchicalSeparators = false; 473 | function wordCount(str) { 474 | return str.split(/\s+/).length; 475 | } 476 | 477 | // If there's a separator in the title, first remove the final part 478 | if ((/ [\|\-\\\/>»] /).test(curTitle)) { 479 | titleHadHierarchicalSeparators = / [\\\/>»] /.test(curTitle); 480 | curTitle = origTitle.replace(/(.*)[\|\-\\\/>»] .*/gi, "$1"); 481 | 482 | // If the resulting title is too short (3 words or fewer), remove 483 | // the first part instead: 484 | if (wordCount(curTitle) < 3) 485 | curTitle = origTitle.replace(/[^\|\-\\\/>»]*[\|\-\\\/>»](.*)/gi, "$1"); 486 | } else if (curTitle.indexOf(": ") !== -1) { 487 | // Check if we have an heading containing this exact string, so we 488 | // could assume it's the full title. 489 | var headings = this._concatNodeLists( 490 | doc.getElementsByTagName("h1"), 491 | doc.getElementsByTagName("h2") 492 | ); 493 | var trimmedTitle = curTitle.trim(); 494 | var match = this._someNode(headings, function(heading) { 495 | return heading.textContent.trim() === trimmedTitle; 496 | }); 497 | 498 | // If we don't, let's extract the title out of the original title string. 499 | if (!match) { 500 | curTitle = origTitle.substring(origTitle.lastIndexOf(":") + 1); 501 | 502 | // If the title is now too short, try the first colon instead: 503 | if (wordCount(curTitle) < 3) { 504 | curTitle = origTitle.substring(origTitle.indexOf(":") + 1); 505 | // But if we have too many words before the colon there's something weird 506 | // with the titles and the H tags so let's just use the original title instead 507 | } else if (wordCount(origTitle.substr(0, origTitle.indexOf(":"))) > 5) { 508 | curTitle = origTitle; 509 | } 510 | } 511 | } else if (curTitle.length > 150 || curTitle.length < 15) { 512 | var hOnes = doc.getElementsByTagName("h1"); 513 | 514 | if (hOnes.length === 1) 515 | curTitle = this._getInnerText(hOnes[0]); 516 | } 517 | 518 | curTitle = curTitle.trim().replace(this.REGEXPS.normalize, " "); 519 | // If we now have 4 words or fewer as our title, and either no 520 | // 'hierarchical' separators (\, /, > or ») were found in the original 521 | // title or we decreased the number of words by more than 1 word, use 522 | // the original title. 523 | var curTitleWordCount = wordCount(curTitle); 524 | if (curTitleWordCount <= 4 && 525 | (!titleHadHierarchicalSeparators || 526 | curTitleWordCount != wordCount(origTitle.replace(/[\|\-\\\/>»]+/g, "")) - 1)) { 527 | curTitle = origTitle; 528 | } 529 | 530 | return curTitle; 531 | }, 532 | 533 | /** 534 | * Prepare the HTML document for readability to scrape it. 535 | * This includes things like stripping javascript, CSS, and handling terrible markup. 536 | * 537 | * @return void 538 | **/ 539 | _prepDocument: function() { 540 | var doc = this._doc; 541 | 542 | // Remove all style tags in head 543 | this._removeNodes(this._getAllNodesWithTag(doc, ["style"])); 544 | 545 | if (doc.body) { 546 | this._replaceBrs(doc.body); 547 | } 548 | 549 | this._replaceNodeTags(this._getAllNodesWithTag(doc, ["font"]), "SPAN"); 550 | }, 551 | 552 | /** 553 | * Finds the next node, starting from the given node, and ignoring 554 | * whitespace in between. If the given node is an element, the same node is 555 | * returned. 556 | */ 557 | _nextNode: function (node) { 558 | var next = node; 559 | while (next 560 | && (next.nodeType != this.ELEMENT_NODE) 561 | && this.REGEXPS.whitespace.test(next.textContent)) { 562 | next = next.nextSibling; 563 | } 564 | return next; 565 | }, 566 | 567 | /** 568 | * Replaces 2 or more successive
elements with a single

. 569 | * Whitespace between
elements are ignored. For example: 570 | *

foo
bar


abc
571 | * will become: 572 | *
foo
bar

abc

573 | */ 574 | _replaceBrs: function (elem) { 575 | this._forEachNode(this._getAllNodesWithTag(elem, ["br"]), function(br) { 576 | var next = br.nextSibling; 577 | 578 | // Whether 2 or more
elements have been found and replaced with a 579 | //

block. 580 | var replaced = false; 581 | 582 | // If we find a
chain, remove the
s until we hit another node 583 | // or non-whitespace. This leaves behind the first
in the chain 584 | // (which will be replaced with a

later). 585 | while ((next = this._nextNode(next)) && (next.tagName == "BR")) { 586 | replaced = true; 587 | var brSibling = next.nextSibling; 588 | next.parentNode.removeChild(next); 589 | next = brSibling; 590 | } 591 | 592 | // If we removed a
chain, replace the remaining
with a

. Add 593 | // all sibling nodes as children of the

until we hit another
594 | // chain. 595 | if (replaced) { 596 | var p = this._doc.createElement("p"); 597 | br.parentNode.replaceChild(p, br); 598 | 599 | next = p.nextSibling; 600 | while (next) { 601 | // If we've hit another

, we're done adding children to this

. 602 | if (next.tagName == "BR") { 603 | var nextElem = this._nextNode(next.nextSibling); 604 | if (nextElem && nextElem.tagName == "BR") 605 | break; 606 | } 607 | 608 | if (!this._isPhrasingContent(next)) 609 | break; 610 | 611 | // Otherwise, make this node a child of the new

. 612 | var sibling = next.nextSibling; 613 | p.appendChild(next); 614 | next = sibling; 615 | } 616 | 617 | while (p.lastChild && this._isWhitespace(p.lastChild)) { 618 | p.removeChild(p.lastChild); 619 | } 620 | 621 | if (p.parentNode.tagName === "P") 622 | this._setNodeTag(p.parentNode, "DIV"); 623 | } 624 | }); 625 | }, 626 | 627 | _setNodeTag: function (node, tag) { 628 | this.log("_setNodeTag", node, tag); 629 | if (this._docJSDOMParser) { 630 | node.localName = tag.toLowerCase(); 631 | node.tagName = tag.toUpperCase(); 632 | return node; 633 | } 634 | 635 | var replacement = node.ownerDocument.createElement(tag); 636 | while (node.firstChild) { 637 | replacement.appendChild(node.firstChild); 638 | } 639 | node.parentNode.replaceChild(replacement, node); 640 | if (node.readability) 641 | replacement.readability = node.readability; 642 | 643 | for (var i = 0; i < node.attributes.length; i++) { 644 | try { 645 | replacement.setAttribute(node.attributes[i].name, node.attributes[i].value); 646 | } catch (ex) { 647 | /* it's possible for setAttribute() to throw if the attribute name 648 | * isn't a valid XML Name. Such attributes can however be parsed from 649 | * source in HTML docs, see https://github.com/whatwg/html/issues/4275, 650 | * so we can hit them here and then throw. We don't care about such 651 | * attributes so we ignore them. 652 | */ 653 | } 654 | } 655 | return replacement; 656 | }, 657 | 658 | /** 659 | * Prepare the article node for display. Clean out any inline styles, 660 | * iframes, forms, strip extraneous

tags, etc. 661 | * 662 | * @param Element 663 | * @return void 664 | **/ 665 | _prepArticle: function(articleContent) { 666 | this._cleanStyles(articleContent); 667 | 668 | // Check for data tables before we continue, to avoid removing items in 669 | // those tables, which will often be isolated even though they're 670 | // visually linked to other content-ful elements (text, images, etc.). 671 | this._markDataTables(articleContent); 672 | 673 | this._fixLazyImages(articleContent); 674 | 675 | // Clean out junk from the article content 676 | this._cleanConditionally(articleContent, "form"); 677 | this._cleanConditionally(articleContent, "fieldset"); 678 | this._clean(articleContent, "object"); 679 | this._clean(articleContent, "embed"); 680 | this._clean(articleContent, "footer"); 681 | this._clean(articleContent, "link"); 682 | this._clean(articleContent, "aside"); 683 | 684 | // Clean out elements with little content that have "share" in their id/class combinations from final top candidates, 685 | // which means we don't remove the top candidates even they have "share". 686 | 687 | var shareElementThreshold = this.DEFAULT_CHAR_THRESHOLD; 688 | 689 | this._forEachNode(articleContent.children, function (topCandidate) { 690 | this._cleanMatchedNodes(topCandidate, function (node, matchString) { 691 | return this.REGEXPS.shareElements.test(matchString) && node.textContent.length < shareElementThreshold; 692 | }); 693 | }); 694 | 695 | this._clean(articleContent, "iframe"); 696 | this._clean(articleContent, "input"); 697 | this._clean(articleContent, "textarea"); 698 | this._clean(articleContent, "select"); 699 | this._clean(articleContent, "button"); 700 | this._cleanHeaders(articleContent); 701 | 702 | // Do these last as the previous stuff may have removed junk 703 | // that will affect these 704 | this._cleanConditionally(articleContent, "table"); 705 | this._cleanConditionally(articleContent, "ul"); 706 | this._cleanConditionally(articleContent, "div"); 707 | 708 | // replace H1 with H2 as H1 should be only title that is displayed separately 709 | this._replaceNodeTags(this._getAllNodesWithTag(articleContent, ["h1"]), "h2"); 710 | 711 | // Remove extra paragraphs 712 | this._removeNodes(this._getAllNodesWithTag(articleContent, ["p"]), function (paragraph) { 713 | var imgCount = paragraph.getElementsByTagName("img").length; 714 | var embedCount = paragraph.getElementsByTagName("embed").length; 715 | var objectCount = paragraph.getElementsByTagName("object").length; 716 | // At this point, nasty iframes have been removed, only remain embedded video ones. 717 | var iframeCount = paragraph.getElementsByTagName("iframe").length; 718 | var totalCount = imgCount + embedCount + objectCount + iframeCount; 719 | 720 | return totalCount === 0 && !this._getInnerText(paragraph, false); 721 | }); 722 | 723 | this._forEachNode(this._getAllNodesWithTag(articleContent, ["br"]), function(br) { 724 | var next = this._nextNode(br.nextSibling); 725 | if (next && next.tagName == "P") 726 | br.parentNode.removeChild(br); 727 | }); 728 | 729 | // Remove single-cell tables 730 | this._forEachNode(this._getAllNodesWithTag(articleContent, ["table"]), function(table) { 731 | var tbody = this._hasSingleTagInsideElement(table, "TBODY") ? table.firstElementChild : table; 732 | if (this._hasSingleTagInsideElement(tbody, "TR")) { 733 | var row = tbody.firstElementChild; 734 | if (this._hasSingleTagInsideElement(row, "TD")) { 735 | var cell = row.firstElementChild; 736 | cell = this._setNodeTag(cell, this._everyNode(cell.childNodes, this._isPhrasingContent) ? "P" : "DIV"); 737 | table.parentNode.replaceChild(cell, table); 738 | } 739 | } 740 | }); 741 | }, 742 | 743 | /** 744 | * Initialize a node with the readability object. Also checks the 745 | * className/id for special names to add to its score. 746 | * 747 | * @param Element 748 | * @return void 749 | **/ 750 | _initializeNode: function(node) { 751 | node.readability = {"contentScore": 0}; 752 | 753 | switch (node.tagName) { 754 | case "DIV": 755 | node.readability.contentScore += 5; 756 | break; 757 | 758 | case "PRE": 759 | case "TD": 760 | case "BLOCKQUOTE": 761 | node.readability.contentScore += 3; 762 | break; 763 | 764 | case "ADDRESS": 765 | case "OL": 766 | case "UL": 767 | case "DL": 768 | case "DD": 769 | case "DT": 770 | case "LI": 771 | case "FORM": 772 | node.readability.contentScore -= 3; 773 | break; 774 | 775 | case "H1": 776 | case "H2": 777 | case "H3": 778 | case "H4": 779 | case "H5": 780 | case "H6": 781 | case "TH": 782 | node.readability.contentScore -= 5; 783 | break; 784 | } 785 | 786 | node.readability.contentScore += this._getClassWeight(node); 787 | }, 788 | 789 | _removeAndGetNext: function(node) { 790 | var nextNode = this._getNextNode(node, true); 791 | node.parentNode.removeChild(node); 792 | return nextNode; 793 | }, 794 | 795 | /** 796 | * Traverse the DOM from node to node, starting at the node passed in. 797 | * Pass true for the second parameter to indicate this node itself 798 | * (and its kids) are going away, and we want the next node over. 799 | * 800 | * Calling this in a loop will traverse the DOM depth-first. 801 | */ 802 | _getNextNode: function(node, ignoreSelfAndKids) { 803 | // First check for kids if those aren't being ignored 804 | if (!ignoreSelfAndKids && node.firstElementChild) { 805 | return node.firstElementChild; 806 | } 807 | // Then for siblings... 808 | if (node.nextElementSibling) { 809 | return node.nextElementSibling; 810 | } 811 | // And finally, move up the parent chain *and* find a sibling 812 | // (because this is depth-first traversal, we will have already 813 | // seen the parent nodes themselves). 814 | do { 815 | node = node.parentNode; 816 | } while (node && !node.nextElementSibling); 817 | return node && node.nextElementSibling; 818 | }, 819 | 820 | // compares second text to first one 821 | // 1 = same text, 0 = completely different text 822 | // works the way that it splits both texts into words and then finds words that are unique in second text 823 | // the result is given by the lower length of unique parts 824 | _textSimilarity: function(textA, textB) { 825 | var tokensA = textA.toLowerCase().split(this.REGEXPS.tokenize).filter(Boolean); 826 | var tokensB = textB.toLowerCase().split(this.REGEXPS.tokenize).filter(Boolean); 827 | if (!tokensA.length || !tokensB.length) { 828 | return 0; 829 | } 830 | var uniqTokensB = tokensB.filter(token => !tokensA.includes(token)); 831 | var distanceB = uniqTokensB.join(" ").length / tokensB.join(" ").length; 832 | return 1 - distanceB; 833 | }, 834 | 835 | _checkByline: function(node, matchString) { 836 | if (this._articleByline) { 837 | return false; 838 | } 839 | 840 | if (node.getAttribute !== undefined) { 841 | var rel = node.getAttribute("rel"); 842 | var itemprop = node.getAttribute("itemprop"); 843 | } 844 | 845 | if ((rel === "author" || (itemprop && itemprop.indexOf("author") !== -1) || this.REGEXPS.byline.test(matchString)) && this._isValidByline(node.textContent)) { 846 | this._articleByline = node.textContent.trim(); 847 | return true; 848 | } 849 | 850 | return false; 851 | }, 852 | 853 | _getNodeAncestors: function(node, maxDepth) { 854 | maxDepth = maxDepth || 0; 855 | var i = 0, ancestors = []; 856 | while (node.parentNode) { 857 | ancestors.push(node.parentNode); 858 | if (maxDepth && ++i === maxDepth) 859 | break; 860 | node = node.parentNode; 861 | } 862 | return ancestors; 863 | }, 864 | 865 | /*** 866 | * grabArticle - Using a variety of metrics (content score, classname, element types), find the content that is 867 | * most likely to be the stuff a user wants to read. Then return it wrapped up in a div. 868 | * 869 | * @param page a document to run upon. Needs to be a full document, complete with body. 870 | * @return Element 871 | **/ 872 | _grabArticle: function (page) { 873 | this.log("**** grabArticle ****"); 874 | var doc = this._doc; 875 | var isPaging = page !== null; 876 | page = page ? page : this._doc.body; 877 | 878 | // We can't grab an article if we don't have a page! 879 | if (!page) { 880 | this.log("No body found in document. Abort."); 881 | return null; 882 | } 883 | 884 | var pageCacheHtml = page.innerHTML; 885 | 886 | while (true) { 887 | this.log("Starting grabArticle loop"); 888 | var stripUnlikelyCandidates = this._flagIsActive(this.FLAG_STRIP_UNLIKELYS); 889 | 890 | // First, node prepping. Trash nodes that look cruddy (like ones with the 891 | // class name "comment", etc), and turn divs into P tags where they have been 892 | // used inappropriately (as in, where they contain no other block level elements.) 893 | var elementsToScore = []; 894 | var node = this._doc.documentElement; 895 | 896 | let shouldRemoveTitleHeader = true; 897 | 898 | while (node) { 899 | var matchString = node.className + " " + node.id; 900 | 901 | if (!this._isProbablyVisible(node)) { 902 | this.log("Removing hidden node - " + matchString); 903 | node = this._removeAndGetNext(node); 904 | continue; 905 | } 906 | 907 | // Check to see if this node is a byline, and remove it if it is. 908 | if (this._checkByline(node, matchString)) { 909 | node = this._removeAndGetNext(node); 910 | continue; 911 | } 912 | 913 | if (shouldRemoveTitleHeader && this._headerDuplicatesTitle(node)) { 914 | this.log("Removing header: ", node.textContent.trim(), this._articleTitle.trim()); 915 | shouldRemoveTitleHeader = false; 916 | node = this._removeAndGetNext(node); 917 | continue; 918 | } 919 | 920 | // Remove unlikely candidates 921 | if (stripUnlikelyCandidates) { 922 | if (this.REGEXPS.unlikelyCandidates.test(matchString) && 923 | !this.REGEXPS.okMaybeItsACandidate.test(matchString) && 924 | !this._hasAncestorTag(node, "table") && 925 | !this._hasAncestorTag(node, "code") && 926 | node.tagName !== "BODY" && 927 | node.tagName !== "A") { 928 | this.log("Removing unlikely candidate - " + matchString); 929 | node = this._removeAndGetNext(node); 930 | continue; 931 | } 932 | 933 | if (this.UNLIKELY_ROLES.includes(node.getAttribute("role"))) { 934 | this.log("Removing content with role " + node.getAttribute("role") + " - " + matchString); 935 | node = this._removeAndGetNext(node); 936 | continue; 937 | } 938 | } 939 | 940 | // Remove DIV, SECTION, and HEADER nodes without any content(e.g. text, image, video, or iframe). 941 | if ((node.tagName === "DIV" || node.tagName === "SECTION" || node.tagName === "HEADER" || 942 | node.tagName === "H1" || node.tagName === "H2" || node.tagName === "H3" || 943 | node.tagName === "H4" || node.tagName === "H5" || node.tagName === "H6") && 944 | this._isElementWithoutContent(node)) { 945 | node = this._removeAndGetNext(node); 946 | continue; 947 | } 948 | 949 | if (this.DEFAULT_TAGS_TO_SCORE.indexOf(node.tagName) !== -1) { 950 | elementsToScore.push(node); 951 | } 952 | 953 | // Turn all divs that don't have children block level elements into p's 954 | if (node.tagName === "DIV") { 955 | // Put phrasing content into paragraphs. 956 | var p = null; 957 | var childNode = node.firstChild; 958 | while (childNode) { 959 | var nextSibling = childNode.nextSibling; 960 | if (this._isPhrasingContent(childNode)) { 961 | if (p !== null) { 962 | p.appendChild(childNode); 963 | } else if (!this._isWhitespace(childNode)) { 964 | p = doc.createElement("p"); 965 | node.replaceChild(p, childNode); 966 | p.appendChild(childNode); 967 | } 968 | } else if (p !== null) { 969 | while (p.lastChild && this._isWhitespace(p.lastChild)) { 970 | p.removeChild(p.lastChild); 971 | } 972 | p = null; 973 | } 974 | childNode = nextSibling; 975 | } 976 | 977 | // Sites like http://mobile.slate.com encloses each paragraph with a DIV 978 | // element. DIVs with only a P element inside and no text content can be 979 | // safely converted into plain P elements to avoid confusing the scoring 980 | // algorithm with DIVs with are, in practice, paragraphs. 981 | if (this._hasSingleTagInsideElement(node, "P") && this._getLinkDensity(node) < 0.25) { 982 | var newNode = node.children[0]; 983 | node.parentNode.replaceChild(newNode, node); 984 | node = newNode; 985 | elementsToScore.push(node); 986 | } else if (!this._hasChildBlockElement(node)) { 987 | node = this._setNodeTag(node, "P"); 988 | elementsToScore.push(node); 989 | } 990 | } 991 | node = this._getNextNode(node); 992 | } 993 | 994 | /** 995 | * Loop through all paragraphs, and assign a score to them based on how content-y they look. 996 | * Then add their score to their parent node. 997 | * 998 | * A score is determined by things like number of commas, class names, etc. Maybe eventually link density. 999 | **/ 1000 | var candidates = []; 1001 | this._forEachNode(elementsToScore, function(elementToScore) { 1002 | if (!elementToScore.parentNode || typeof(elementToScore.parentNode.tagName) === "undefined") 1003 | return; 1004 | 1005 | // If this paragraph is less than 25 characters, don't even count it. 1006 | var innerText = this._getInnerText(elementToScore); 1007 | if (innerText.length < 25) 1008 | return; 1009 | 1010 | // Exclude nodes with no ancestor. 1011 | var ancestors = this._getNodeAncestors(elementToScore, 5); 1012 | if (ancestors.length === 0) 1013 | return; 1014 | 1015 | var contentScore = 0; 1016 | 1017 | // Add a point for the paragraph itself as a base. 1018 | contentScore += 1; 1019 | 1020 | // Add points for any commas within this paragraph. 1021 | contentScore += innerText.split(",").length; 1022 | 1023 | // For every 100 characters in this paragraph, add another point. Up to 3 points. 1024 | contentScore += Math.min(Math.floor(innerText.length / 100), 3); 1025 | 1026 | // Initialize and score ancestors. 1027 | this._forEachNode(ancestors, function(ancestor, level) { 1028 | if (!ancestor.tagName || !ancestor.parentNode || typeof(ancestor.parentNode.tagName) === "undefined") 1029 | return; 1030 | 1031 | if (typeof(ancestor.readability) === "undefined") { 1032 | this._initializeNode(ancestor); 1033 | candidates.push(ancestor); 1034 | } 1035 | 1036 | // Node score divider: 1037 | // - parent: 1 (no division) 1038 | // - grandparent: 2 1039 | // - great grandparent+: ancestor level * 3 1040 | if (level === 0) 1041 | var scoreDivider = 1; 1042 | else if (level === 1) 1043 | scoreDivider = 2; 1044 | else 1045 | scoreDivider = level * 3; 1046 | ancestor.readability.contentScore += contentScore / scoreDivider; 1047 | }); 1048 | }); 1049 | 1050 | // After we've calculated scores, loop through all of the possible 1051 | // candidate nodes we found and find the one with the highest score. 1052 | var topCandidates = []; 1053 | for (var c = 0, cl = candidates.length; c < cl; c += 1) { 1054 | var candidate = candidates[c]; 1055 | 1056 | // Scale the final candidates score based on link density. Good content 1057 | // should have a relatively small link density (5% or less) and be mostly 1058 | // unaffected by this operation. 1059 | var candidateScore = candidate.readability.contentScore * (1 - this._getLinkDensity(candidate)); 1060 | candidate.readability.contentScore = candidateScore; 1061 | 1062 | this.log("Candidate:", candidate, "with score " + candidateScore); 1063 | 1064 | for (var t = 0; t < this._nbTopCandidates; t++) { 1065 | var aTopCandidate = topCandidates[t]; 1066 | 1067 | if (!aTopCandidate || candidateScore > aTopCandidate.readability.contentScore) { 1068 | topCandidates.splice(t, 0, candidate); 1069 | if (topCandidates.length > this._nbTopCandidates) 1070 | topCandidates.pop(); 1071 | break; 1072 | } 1073 | } 1074 | } 1075 | 1076 | var topCandidate = topCandidates[0] || null; 1077 | var neededToCreateTopCandidate = false; 1078 | var parentOfTopCandidate; 1079 | 1080 | // If we still have no top candidate, just use the body as a last resort. 1081 | // We also have to copy the body node so it is something we can modify. 1082 | if (topCandidate === null || topCandidate.tagName === "BODY") { 1083 | // Move all of the page's children into topCandidate 1084 | topCandidate = doc.createElement("DIV"); 1085 | neededToCreateTopCandidate = true; 1086 | // Move everything (not just elements, also text nodes etc.) into the container 1087 | // so we even include text directly in the body: 1088 | var kids = page.childNodes; 1089 | while (kids.length) { 1090 | this.log("Moving child out:", kids[0]); 1091 | topCandidate.appendChild(kids[0]); 1092 | } 1093 | 1094 | page.appendChild(topCandidate); 1095 | 1096 | this._initializeNode(topCandidate); 1097 | } else if (topCandidate) { 1098 | // Find a better top candidate node if it contains (at least three) nodes which belong to `topCandidates` array 1099 | // and whose scores are quite closed with current `topCandidate` node. 1100 | var alternativeCandidateAncestors = []; 1101 | for (var i = 1; i < topCandidates.length; i++) { 1102 | if (topCandidates[i].readability.contentScore / topCandidate.readability.contentScore >= 0.75) { 1103 | alternativeCandidateAncestors.push(this._getNodeAncestors(topCandidates[i])); 1104 | } 1105 | } 1106 | var MINIMUM_TOPCANDIDATES = 3; 1107 | if (alternativeCandidateAncestors.length >= MINIMUM_TOPCANDIDATES) { 1108 | parentOfTopCandidate = topCandidate.parentNode; 1109 | while (parentOfTopCandidate.tagName !== "BODY") { 1110 | var listsContainingThisAncestor = 0; 1111 | for (var ancestorIndex = 0; ancestorIndex < alternativeCandidateAncestors.length && listsContainingThisAncestor < MINIMUM_TOPCANDIDATES; ancestorIndex++) { 1112 | listsContainingThisAncestor += Number(alternativeCandidateAncestors[ancestorIndex].includes(parentOfTopCandidate)); 1113 | } 1114 | if (listsContainingThisAncestor >= MINIMUM_TOPCANDIDATES) { 1115 | topCandidate = parentOfTopCandidate; 1116 | break; 1117 | } 1118 | parentOfTopCandidate = parentOfTopCandidate.parentNode; 1119 | } 1120 | } 1121 | if (!topCandidate.readability) { 1122 | this._initializeNode(topCandidate); 1123 | } 1124 | 1125 | // Because of our bonus system, parents of candidates might have scores 1126 | // themselves. They get half of the node. There won't be nodes with higher 1127 | // scores than our topCandidate, but if we see the score going *up* in the first 1128 | // few steps up the tree, that's a decent sign that there might be more content 1129 | // lurking in other places that we want to unify in. The sibling stuff 1130 | // below does some of that - but only if we've looked high enough up the DOM 1131 | // tree. 1132 | parentOfTopCandidate = topCandidate.parentNode; 1133 | var lastScore = topCandidate.readability.contentScore; 1134 | // The scores shouldn't get too low. 1135 | var scoreThreshold = lastScore / 3; 1136 | while (parentOfTopCandidate.tagName !== "BODY") { 1137 | if (!parentOfTopCandidate.readability) { 1138 | parentOfTopCandidate = parentOfTopCandidate.parentNode; 1139 | continue; 1140 | } 1141 | var parentScore = parentOfTopCandidate.readability.contentScore; 1142 | if (parentScore < scoreThreshold) 1143 | break; 1144 | if (parentScore > lastScore) { 1145 | // Alright! We found a better parent to use. 1146 | topCandidate = parentOfTopCandidate; 1147 | break; 1148 | } 1149 | lastScore = parentOfTopCandidate.readability.contentScore; 1150 | parentOfTopCandidate = parentOfTopCandidate.parentNode; 1151 | } 1152 | 1153 | // If the top candidate is the only child, use parent instead. This will help sibling 1154 | // joining logic when adjacent content is actually located in parent's sibling node. 1155 | parentOfTopCandidate = topCandidate.parentNode; 1156 | while (parentOfTopCandidate.tagName != "BODY" && parentOfTopCandidate.children.length == 1) { 1157 | topCandidate = parentOfTopCandidate; 1158 | parentOfTopCandidate = topCandidate.parentNode; 1159 | } 1160 | if (!topCandidate.readability) { 1161 | this._initializeNode(topCandidate); 1162 | } 1163 | } 1164 | 1165 | // Now that we have the top candidate, look through its siblings for content 1166 | // that might also be related. Things like preambles, content split by ads 1167 | // that we removed, etc. 1168 | var articleContent = doc.createElement("DIV"); 1169 | if (isPaging) 1170 | articleContent.id = "readability-content"; 1171 | 1172 | var siblingScoreThreshold = Math.max(10, topCandidate.readability.contentScore * 0.2); 1173 | // Keep potential top candidate's parent node to try to get text direction of it later. 1174 | parentOfTopCandidate = topCandidate.parentNode; 1175 | var siblings = parentOfTopCandidate.children; 1176 | 1177 | for (var s = 0, sl = siblings.length; s < sl; s++) { 1178 | var sibling = siblings[s]; 1179 | var append = false; 1180 | 1181 | this.log("Looking at sibling node:", sibling, sibling.readability ? ("with score " + sibling.readability.contentScore) : ""); 1182 | this.log("Sibling has score", sibling.readability ? sibling.readability.contentScore : "Unknown"); 1183 | 1184 | if (sibling === topCandidate) { 1185 | append = true; 1186 | } else { 1187 | var contentBonus = 0; 1188 | 1189 | // Give a bonus if sibling nodes and top candidates have the example same classname 1190 | if (sibling.className === topCandidate.className && topCandidate.className !== "") 1191 | contentBonus += topCandidate.readability.contentScore * 0.2; 1192 | 1193 | if (sibling.readability && 1194 | ((sibling.readability.contentScore + contentBonus) >= siblingScoreThreshold)) { 1195 | append = true; 1196 | } else if (sibling.nodeName === "P") { 1197 | var linkDensity = this._getLinkDensity(sibling); 1198 | var nodeContent = this._getInnerText(sibling); 1199 | var nodeLength = nodeContent.length; 1200 | 1201 | if (nodeLength > 80 && linkDensity < 0.25) { 1202 | append = true; 1203 | } else if (nodeLength < 80 && nodeLength > 0 && linkDensity === 0 && 1204 | nodeContent.search(/\.( |$)/) !== -1) { 1205 | append = true; 1206 | } 1207 | } 1208 | } 1209 | 1210 | if (append) { 1211 | this.log("Appending node:", sibling); 1212 | 1213 | if (this.ALTER_TO_DIV_EXCEPTIONS.indexOf(sibling.nodeName) === -1) { 1214 | // We have a node that isn't a common block level element, like a form or td tag. 1215 | // Turn it into a div so it doesn't get filtered out later by accident. 1216 | this.log("Altering sibling:", sibling, "to div."); 1217 | 1218 | sibling = this._setNodeTag(sibling, "DIV"); 1219 | } 1220 | 1221 | articleContent.appendChild(sibling); 1222 | // siblings is a reference to the children array, and 1223 | // sibling is removed from the array when we call appendChild(). 1224 | // As a result, we must revisit this index since the nodes 1225 | // have been shifted. 1226 | s -= 1; 1227 | sl -= 1; 1228 | } 1229 | } 1230 | 1231 | if (this._debug) 1232 | this.log("Article content pre-prep: " + articleContent.innerHTML); 1233 | // So we have all of the content that we need. Now we clean it up for presentation. 1234 | this._prepArticle(articleContent); 1235 | if (this._debug) 1236 | this.log("Article content post-prep: " + articleContent.innerHTML); 1237 | 1238 | if (neededToCreateTopCandidate) { 1239 | // We already created a fake div thing, and there wouldn't have been any siblings left 1240 | // for the previous loop, so there's no point trying to create a new div, and then 1241 | // move all the children over. Just assign IDs and class names here. No need to append 1242 | // because that already happened anyway. 1243 | topCandidate.id = "readability-page-1"; 1244 | topCandidate.className = "page"; 1245 | } else { 1246 | var div = doc.createElement("DIV"); 1247 | div.id = "readability-page-1"; 1248 | div.className = "page"; 1249 | while (articleContent.firstChild) { 1250 | div.appendChild(articleContent.firstChild); 1251 | } 1252 | articleContent.appendChild(div); 1253 | } 1254 | 1255 | if (this._debug) 1256 | this.log("Article content after paging: " + articleContent.innerHTML); 1257 | 1258 | var parseSuccessful = true; 1259 | 1260 | // Now that we've gone through the full algorithm, check to see if 1261 | // we got any meaningful content. If we didn't, we may need to re-run 1262 | // grabArticle with different flags set. This gives us a higher likelihood of 1263 | // finding the content, and the sieve approach gives us a higher likelihood of 1264 | // finding the -right- content. 1265 | var textLength = this._getInnerText(articleContent, true).length; 1266 | if (textLength < this._charThreshold) { 1267 | parseSuccessful = false; 1268 | 1269 | // replaces: page.innerHTML = pageCacheHtml with DOMParser for safety 1270 | var dom = new DOMParser().parseFromString('', 'text/html').head 1271 | page.appendChild(dom.firstElementChild.content) 1272 | 1273 | if (this._flagIsActive(this.FLAG_STRIP_UNLIKELYS)) { 1274 | this._removeFlag(this.FLAG_STRIP_UNLIKELYS); 1275 | this._attempts.push({articleContent: articleContent, textLength: textLength}); 1276 | } else if (this._flagIsActive(this.FLAG_WEIGHT_CLASSES)) { 1277 | this._removeFlag(this.FLAG_WEIGHT_CLASSES); 1278 | this._attempts.push({articleContent: articleContent, textLength: textLength}); 1279 | } else if (this._flagIsActive(this.FLAG_CLEAN_CONDITIONALLY)) { 1280 | this._removeFlag(this.FLAG_CLEAN_CONDITIONALLY); 1281 | this._attempts.push({articleContent: articleContent, textLength: textLength}); 1282 | } else { 1283 | this._attempts.push({articleContent: articleContent, textLength: textLength}); 1284 | // No luck after removing flags, just return the longest text we found during the different loops 1285 | this._attempts.sort(function (a, b) { 1286 | return b.textLength - a.textLength; 1287 | }); 1288 | 1289 | // But first check if we actually have something 1290 | if (!this._attempts[0].textLength) { 1291 | return null; 1292 | } 1293 | 1294 | articleContent = this._attempts[0].articleContent; 1295 | parseSuccessful = true; 1296 | } 1297 | } 1298 | 1299 | if (parseSuccessful) { 1300 | // Find out text direction from ancestors of final top candidate. 1301 | var ancestors = [parentOfTopCandidate, topCandidate].concat(this._getNodeAncestors(parentOfTopCandidate)); 1302 | this._someNode(ancestors, function(ancestor) { 1303 | if (!ancestor.tagName) 1304 | return false; 1305 | var articleDir = ancestor.getAttribute("dir"); 1306 | if (articleDir) { 1307 | this._articleDir = articleDir; 1308 | return true; 1309 | } 1310 | return false; 1311 | }); 1312 | return articleContent; 1313 | } 1314 | } 1315 | }, 1316 | 1317 | /** 1318 | * Check whether the input string could be a byline. 1319 | * This verifies that the input is a string, and that the length 1320 | * is less than 100 chars. 1321 | * 1322 | * @param possibleByline {string} - a string to check whether its a byline. 1323 | * @return Boolean - whether the input string is a byline. 1324 | */ 1325 | _isValidByline: function(byline) { 1326 | if (typeof byline == "string" || byline instanceof String) { 1327 | byline = byline.trim(); 1328 | return (byline.length > 0) && (byline.length < 100); 1329 | } 1330 | return false; 1331 | }, 1332 | 1333 | /** 1334 | * Converts some of the common HTML entities in string to their corresponding characters. 1335 | * 1336 | * @param str {string} - a string to unescape. 1337 | * @return string without HTML entity. 1338 | */ 1339 | _unescapeHtmlEntities: function(str) { 1340 | if (!str) { 1341 | return str; 1342 | } 1343 | 1344 | var htmlEscapeMap = this.HTML_ESCAPE_MAP; 1345 | return str.replace(/&(quot|amp|apos|lt|gt);/g, function(_, tag) { 1346 | return htmlEscapeMap[tag]; 1347 | }).replace(/&#(?:x([0-9a-z]{1,4})|([0-9]{1,4}));/gi, function(_, hex, numStr) { 1348 | var num = parseInt(hex || numStr, hex ? 16 : 10); 1349 | return String.fromCharCode(num); 1350 | }); 1351 | }, 1352 | 1353 | /** 1354 | * Try to extract metadata from JSON-LD object. 1355 | * For now, only Schema.org objects of type Article or its subtypes are supported. 1356 | * @return Object with any metadata that could be extracted (possibly none) 1357 | */ 1358 | _getJSONLD: function (doc) { 1359 | var scripts = this._getAllNodesWithTag(doc, ["script"]); 1360 | 1361 | var jsonLdElement = this._findNode(scripts, function(el) { 1362 | return el.getAttribute("type") === "application/ld+json"; 1363 | }); 1364 | 1365 | if (jsonLdElement) { 1366 | try { 1367 | // Strip CDATA markers if present 1368 | var content = jsonLdElement.textContent.replace(/^\s*\s*$/g, ""); 1369 | var parsed = JSON.parse(content); 1370 | var metadata = {}; 1371 | if ( 1372 | !parsed["@context"] || 1373 | !parsed["@context"].match(/^https?\:\/\/schema\.org$/) 1374 | ) { 1375 | return metadata; 1376 | } 1377 | 1378 | if (!parsed["@type"] && Array.isArray(parsed["@graph"])) { 1379 | parsed = parsed["@graph"].find(function(it) { 1380 | return (it["@type"] || "").match( 1381 | this.REGEXPS.jsonLdArticleTypes 1382 | ); 1383 | }); 1384 | } 1385 | 1386 | if ( 1387 | !parsed || 1388 | !parsed["@type"] || 1389 | !parsed["@type"].match(this.REGEXPS.jsonLdArticleTypes) 1390 | ) { 1391 | return metadata; 1392 | } 1393 | if (typeof parsed.name === "string") { 1394 | metadata.title = parsed.name.trim(); 1395 | } else if (typeof parsed.headline === "string") { 1396 | metadata.title = parsed.headline.trim(); 1397 | } 1398 | if (parsed.author) { 1399 | if (typeof parsed.author.name === "string") { 1400 | metadata.byline = parsed.author.name.trim(); 1401 | } else if (Array.isArray(parsed.author) && parsed.author[0] && typeof parsed.author[0].name === "string") { 1402 | metadata.byline = parsed.author 1403 | .filter(function(author) { 1404 | return author && typeof author.name === "string"; 1405 | }) 1406 | .map(function(author) { 1407 | return author.name.trim(); 1408 | }) 1409 | .join(", "); 1410 | } 1411 | } 1412 | if (typeof parsed.description === "string") { 1413 | metadata.excerpt = parsed.description.trim(); 1414 | } 1415 | if ( 1416 | parsed.publisher && 1417 | typeof parsed.publisher.name === "string" 1418 | ) { 1419 | metadata.siteName = parsed.publisher.name.trim(); 1420 | } 1421 | return metadata; 1422 | } catch (err) { 1423 | this.log(err.message); 1424 | } 1425 | } 1426 | return {}; 1427 | }, 1428 | 1429 | /** 1430 | * Attempts to get excerpt and byline metadata for the article. 1431 | * 1432 | * @param {Object} jsonld — object containing any metadata that 1433 | * could be extracted from JSON-LD object. 1434 | * 1435 | * @return Object with optional "excerpt" and "byline" properties 1436 | */ 1437 | _getArticleMetadata: function(jsonld) { 1438 | var metadata = {}; 1439 | var values = {}; 1440 | var metaElements = this._doc.getElementsByTagName("meta"); 1441 | 1442 | // property is a space-separated list of values 1443 | var propertyPattern = /\s*(dc|dcterm|og|twitter)\s*:\s*(author|creator|description|title|site_name)\s*/gi; 1444 | 1445 | // name is a single value 1446 | var namePattern = /^\s*(?:(dc|dcterm|og|twitter|weibo:(article|webpage))\s*[\.:]\s*)?(author|creator|description|title|site_name)\s*$/i; 1447 | 1448 | // Find description tags. 1449 | this._forEachNode(metaElements, function(element) { 1450 | var elementName = element.getAttribute("name"); 1451 | var elementProperty = element.getAttribute("property"); 1452 | var content = element.getAttribute("content"); 1453 | if (!content) { 1454 | return; 1455 | } 1456 | var matches = null; 1457 | var name = null; 1458 | 1459 | if (elementProperty) { 1460 | matches = elementProperty.match(propertyPattern); 1461 | if (matches) { 1462 | // Convert to lowercase, and remove any whitespace 1463 | // so we can match below. 1464 | name = matches[0].toLowerCase().replace(/\s/g, ""); 1465 | // multiple authors 1466 | values[name] = content.trim(); 1467 | } 1468 | } 1469 | if (!matches && elementName && namePattern.test(elementName)) { 1470 | name = elementName; 1471 | if (content) { 1472 | // Convert to lowercase, remove any whitespace, and convert dots 1473 | // to colons so we can match below. 1474 | name = name.toLowerCase().replace(/\s/g, "").replace(/\./g, ":"); 1475 | values[name] = content.trim(); 1476 | } 1477 | } 1478 | }); 1479 | 1480 | // get title 1481 | metadata.title = jsonld.title || 1482 | values["dc:title"] || 1483 | values["dcterm:title"] || 1484 | values["og:title"] || 1485 | values["weibo:article:title"] || 1486 | values["weibo:webpage:title"] || 1487 | values["title"] || 1488 | values["twitter:title"]; 1489 | 1490 | if (!metadata.title) { 1491 | metadata.title = this._getArticleTitle(); 1492 | } 1493 | 1494 | // get author 1495 | metadata.byline = jsonld.byline || 1496 | values["dc:creator"] || 1497 | values["dcterm:creator"] || 1498 | values["author"]; 1499 | 1500 | // get description 1501 | metadata.excerpt = jsonld.excerpt || 1502 | values["dc:description"] || 1503 | values["dcterm:description"] || 1504 | values["og:description"] || 1505 | values["weibo:article:description"] || 1506 | values["weibo:webpage:description"] || 1507 | values["description"] || 1508 | values["twitter:description"]; 1509 | 1510 | // get site name 1511 | metadata.siteName = jsonld.siteName || 1512 | values["og:site_name"]; 1513 | 1514 | // in many sites the meta value is escaped with HTML entities, 1515 | // so here we need to unescape it 1516 | metadata.title = this._unescapeHtmlEntities(metadata.title); 1517 | metadata.byline = this._unescapeHtmlEntities(metadata.byline); 1518 | metadata.excerpt = this._unescapeHtmlEntities(metadata.excerpt); 1519 | metadata.siteName = this._unescapeHtmlEntities(metadata.siteName); 1520 | 1521 | return metadata; 1522 | }, 1523 | 1524 | /** 1525 | * Check if node is image, or if node contains exactly only one image 1526 | * whether as a direct child or as its descendants. 1527 | * 1528 | * @param Element 1529 | **/ 1530 | _isSingleImage: function(node) { 1531 | if (node.tagName === "IMG") { 1532 | return true; 1533 | } 1534 | 1535 | if (node.children.length !== 1 || node.textContent.trim() !== "") { 1536 | return false; 1537 | } 1538 | 1539 | return this._isSingleImage(node.children[0]); 1540 | }, 1541 | 1542 | /** 1543 | * Find all