Created
August 18, 2026 04:37
-
-
Save chapmanjacobd/5da39a3300b77d033c2d59872e000f23 to your computer and use it in GitHub Desktop.
article splitter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Reading Tracker — bookmarklet | |
| * ----------------------------- | |
| * Click once on a long article: | |
| * 1. Inserts a plain "Section N" heading (with an #rt-section-N anchor) | |
| * roughly every 2,500 words — split at the nearest paragraph break, | |
| * or if none is close by, at the nearest sentence boundary. | |
| * 2. Silently remembers how far down the page you scroll. | |
| * Come back later and, if you didn't finish, a small "Jump to last | |
| * position" link appears top-left. Click it, it scrolls you back and | |
| * disappears. | |
| * | |
| * Click the bookmarklet again on the same page to turn it off — this | |
| * removes the resume link and un-splits every paragraph, restoring the | |
| * page exactly as it was. | |
| * | |
| * Tuning knobs: TARGET_WORDS (heading spacing) and NEAR_BOUNDARY (how | |
| * close a paragraph break has to be before a sentence-level split is | |
| * used instead). | |
| * | |
| * Everything is stored in localStorage, scoped to whatever site you're | |
| * on. Nothing is sent anywhere. | |
| */ | |
| function readingTrackerBookmarklet() { | |
| (function () { | |
| try { | |
| // toggle off if already active on this page | |
| if (window.__rtCleanup) { window.__rtCleanup(); return; } | |
| var PAGE_KEY = 'rt:page:' + location.href.split('#')[0]; | |
| var TARGET_WORDS = 2500; // insert a heading roughly every N words | |
| var NEAR_BOUNDARY = 150; // if a paragraph break is within N words of the target, use it | |
| function loadJSON(key, fb) { try { var v = JSON.parse(localStorage.getItem(key)); return v || fb; } catch (e) { return fb; } } | |
| function saveJSON(key, val) { try { localStorage.setItem(key, JSON.stringify(val)); } catch (e) {} } | |
| function wc(str) { var m = (str || '').trim().match(/\S+/g); return m ? m.length : 0; } | |
| function findMain() { | |
| var sels = ['article', 'main', '[role="main"]', '#content', '.post-content', '.article-content', '.entry-content']; | |
| for (var i = 0; i < sels.length; i++) { | |
| var el = document.querySelector(sels[i]); | |
| if (el && wc(el.innerText) > 300) return el; | |
| } | |
| return document.body; | |
| } | |
| var mainEl = findMain(); | |
| // find the sentence-ending word (., !, or ?) closest to a target word index within a paragraph | |
| function findSentenceBoundary(paraEl, targetWordIdx) { | |
| var walker = document.createTreeWalker(paraEl, NodeFilter.SHOW_TEXT, null); | |
| var node, count = 0, best = null, bestDiff = Infinity; | |
| while ((node = walker.nextNode())) { | |
| var text = node.textContent; | |
| var re = /\S+/g, mm; | |
| while ((mm = re.exec(text))) { | |
| count++; | |
| var token = mm[0]; | |
| if (/[.!?]["')\]]*$/.test(token)) { | |
| var diff = Math.abs(count - targetWordIdx); | |
| if (diff < bestDiff) { | |
| bestDiff = diff; | |
| best = { node: node, offset: mm.index + token.length }; | |
| } | |
| } | |
| } | |
| } | |
| return best; | |
| } | |
| // ---------- 1. insert deterministic section headings ---------- | |
| var insertedHeadings = []; | |
| var splitRecords = []; // {firstHalf, secondHalf} — kept so toggling off can rejoin paragraphs exactly | |
| var SKIP_ANCESTOR_SEL = 'nav,aside,figure,footer,header,figcaption,blockquote,pre,code'; | |
| var paras = Array.prototype.filter.call(mainEl.querySelectorAll('p'), function (p) { | |
| if (wc(p.textContent) < 3) return false; | |
| var bad = p.closest(SKIP_ANCESTOR_SEL); | |
| return !(bad && mainEl.contains(bad)); | |
| }); | |
| if (paras.length >= 3) { | |
| var cum = [], running = 0; | |
| for (var i = 0; i < paras.length; i++) { running += wc(paras[i].textContent); cum.push(running); } | |
| var total = running; | |
| var targets = []; | |
| for (var t = TARGET_WORDS; t < total - 300; t += TARGET_WORDS) targets.push(t); | |
| var n = 2; // content before the first heading is implicitly "Section 1" | |
| var lastIdx = -1; | |
| for (var ti = 0; ti < targets.length; ti++) { | |
| var target = targets[ti]; | |
| var idx = -1; | |
| for (var i2 = 0; i2 < cum.length; i2++) { if (cum[i2] >= target) { idx = i2; break; } } | |
| if (idx === -1 || idx === lastIdx) continue; // skip if it lands back in an already-split paragraph | |
| lastIdx = idx; | |
| var startOfIdx = idx === 0 ? 0 : cum[idx - 1]; | |
| var distBefore = target - startOfIdx; | |
| var distAfter = cum[idx] - target; | |
| var heading = document.createElement('h2'); | |
| var secId = 'rt-section-' + n; | |
| while (document.getElementById(secId)) secId += 'x'; | |
| heading.id = secId; | |
| heading.textContent = 'Section ' + n; | |
| heading.style.cssText = 'margin:2.4em 0 1em!important;padding-top:1em!important;border-top:1px solid rgba(120,120,120,.35)!important;font:600 13px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif!important;letter-spacing:.08em!important;text-transform:uppercase!important;color:#8a8a8a!important;background:none!important;'; | |
| if (Math.min(distBefore, distAfter) <= NEAR_BOUNDARY) { | |
| // near an existing paragraph break — use it, no text is touched | |
| if (distBefore <= distAfter) { | |
| paras[idx].parentNode.insertBefore(heading, paras[idx]); | |
| } else if (paras[idx].nextSibling) { | |
| paras[idx].parentNode.insertBefore(heading, paras[idx].nextSibling); | |
| } else { | |
| paras[idx].parentNode.appendChild(heading); | |
| } | |
| } else { | |
| // no nearby break — split this paragraph at the nearest sentence boundary | |
| var localTarget = target - startOfIdx; | |
| var boundary = findSentenceBoundary(paras[idx], localTarget); | |
| if (!boundary) { | |
| paras[idx].parentNode.insertBefore(heading, paras[idx]); | |
| } else { | |
| var range = document.createRange(); | |
| range.setStart(paras[idx], 0); | |
| range.setEnd(boundary.node, boundary.offset); | |
| var frag = range.extractContents(); // moves nodes out, doesn't lose or duplicate anything | |
| var firstHalf = document.createElement(paras[idx].tagName); | |
| if (paras[idx].className) firstHalf.className = paras[idx].className; | |
| firstHalf.appendChild(frag); | |
| paras[idx].parentNode.insertBefore(firstHalf, paras[idx]); | |
| paras[idx].parentNode.insertBefore(heading, paras[idx]); | |
| splitRecords.push({ firstHalf: firstHalf, secondHalf: paras[idx] }); | |
| } | |
| } | |
| insertedHeadings.push(heading); | |
| n++; | |
| } | |
| } | |
| // ---------- 2. minimal resume link + silent progress tracking ---------- | |
| var saved = loadJSON(PAGE_KEY, null); | |
| var prevMax = saved ? saved.maxProgress : 0; | |
| var resumeLink = null; | |
| if (prevMax >= 5 && prevMax < 95) { | |
| resumeLink = document.createElement('a'); | |
| resumeLink.href = '#'; | |
| resumeLink.textContent = 'Jump to last position'; | |
| resumeLink.style.cssText = 'position:fixed!important;top:12px!important;left:12px!important;z-index:2147483647!important;background:rgba(20,20,22,.88)!important;color:#f0ece1!important;font:500 12.5px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif!important;padding:7px 11px!important;border-radius:8px!important;text-decoration:none!important;box-shadow:0 4px 14px rgba(0,0,0,.25)!important;opacity:.92!important;transition:opacity .4s ease!important;'; | |
| resumeLink.addEventListener('mouseenter', function () { resumeLink.style.opacity = '1'; }); | |
| resumeLink.addEventListener('mouseleave', function () { resumeLink.style.opacity = '.92'; }); | |
| resumeLink.addEventListener('click', function (e) { | |
| e.preventDefault(); | |
| var dest = Math.round((prevMax / 100) * (document.documentElement.scrollHeight - window.innerHeight)); | |
| window.scrollTo({ top: dest, behavior: 'smooth' }); | |
| resumeLink.style.opacity = '0'; | |
| setTimeout(function () { if (resumeLink.parentNode) resumeLink.remove(); }, 450); | |
| }); | |
| document.body.appendChild(resumeLink); | |
| } | |
| var maxProgress = prevMax; | |
| function pct() { | |
| var top = window.scrollY || document.documentElement.scrollTop; | |
| var h = document.documentElement.scrollHeight - window.innerHeight; | |
| if (h <= 0) return 100; | |
| return Math.min(100, Math.max(0, Math.round((top / h) * 100))); | |
| } | |
| function persist() { | |
| var p = pct(); | |
| if (p > maxProgress) maxProgress = p; | |
| saveJSON(PAGE_KEY, { maxProgress: maxProgress, timestamp: Date.now() }); | |
| } | |
| function onVisChange() { if (document.hidden) persist(); } | |
| window.addEventListener('scroll', persist, { passive: true }); | |
| window.addEventListener('beforeunload', persist); | |
| document.addEventListener('visibilitychange', onVisChange); | |
| persist(); | |
| // ---------- 3. toggle off: fully revert ---------- | |
| window.__rtCleanup = function () { | |
| window.removeEventListener('scroll', persist); | |
| window.removeEventListener('beforeunload', persist); | |
| document.removeEventListener('visibilitychange', onVisChange); | |
| if (resumeLink && resumeLink.parentNode) resumeLink.remove(); | |
| for (var i = 0; i < splitRecords.length; i++) { | |
| var rec = splitRecords[i]; | |
| var frag2 = document.createDocumentFragment(); | |
| while (rec.firstHalf.firstChild) frag2.appendChild(rec.firstHalf.firstChild); | |
| rec.secondHalf.insertBefore(frag2, rec.secondHalf.firstChild); | |
| if (rec.firstHalf.parentNode) rec.firstHalf.parentNode.removeChild(rec.firstHalf); | |
| } | |
| for (var j = 0; j < insertedHeadings.length; j++) { | |
| if (insertedHeadings[j].parentNode) insertedHeadings[j].parentNode.removeChild(insertedHeadings[j]); | |
| } | |
| delete window.__rtCleanup; | |
| }; | |
| } catch (err) { console.error('Reading Tracker error:', err); } | |
| })(); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment