@zsoltcsaszti/lens 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # Lens SDK
2
+
3
+ [![npm](https://img.shields.io/npm/v/@zsolt/lens)](https://www.npmjs.com/package/@zsolt/lens)
4
+ [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
5
+
6
+ Reader intelligence for publishers. One script tag. Zero tracking cookies.
7
+
8
+ ---
9
+
10
+ ## Install
11
+
12
+ **Via CDN (recommended for most publishers):**
13
+
14
+ ```html
15
+ <script
16
+ src="https://unpkg.com/@zsolt/lens/dist/lens.umd.js"
17
+ data-site-id="YOUR_SITE_ID"
18
+ data-api="https://your-lens-server.com"
19
+ data-mode="emphasis">
20
+ </script>
21
+ ```
22
+
23
+ Paste before `</body>` on every page. That's it — no API keys for readers, no accounts, no configuration files.
24
+
25
+ **Via npm (for bundler-based setups):**
26
+
27
+ ```bash
28
+ npm install @zsolt/lens
29
+ ```
30
+
31
+ ```js
32
+ import '@zsolt/lens' // side-effect import — auto-initialises from data-* attributes
33
+ ```
34
+
35
+ **WordPress:** copy [`wordpress/lens-analytics/`](wordpress/lens-analytics/) into `wp-content/plugins/`, activate, and configure under **Settings → Lens Analytics**.
36
+
37
+ ---
38
+
39
+ ## What it does
40
+
41
+ Lens silently observes how each visitor reads — which sections they dwell on, re-read, copy — and builds an anonymous interest profile in their browser. After a couple of visits, it subtly adapts section emphasis so each reader sees what matters most to them first.
42
+
43
+ **What Lens never does:**
44
+ - Never rewrites a word of your content
45
+ - Never sends browsing history or personal data anywhere
46
+ - Never requires readers to sign up or install anything
47
+
48
+ The visitor profile lives entirely in `localStorage`. Only anonymised engagement metrics (section dwell times, scroll depth) are sent to the analytics server.
49
+
50
+ ---
51
+
52
+ ## Live demos
53
+
54
+ Run `npm start` then open:
55
+
56
+ | URL | What you see |
57
+ |-----|-------------|
58
+ | `http://localhost:3000/` | Demo landing page |
59
+ | `http://localhost:3000/demo/article-tech.html` | Tech deep-dive article |
60
+ | `http://localhost:3000/demo/article-breaking-news.html` | Breaking news style |
61
+ | `http://localhost:3000/demo/article-magazine.html` | Long-form magazine essay |
62
+ | `http://localhost:3000/demo/article-tutorial.html` | Step-by-step tutorial |
63
+ | `http://localhost:3000/dashboard?site=demo` | Publisher analytics dashboard |
64
+ | `http://localhost:3000/public/index.html` | Privacy positioning page |
65
+
66
+ ---
67
+
68
+ ## Run the analytics server
69
+
70
+ ```bash
71
+ cd lens-sdk
72
+ npm install
73
+ npm start # production
74
+ npm run dev # auto-restart on file change
75
+ ```
76
+
77
+ Server starts at `http://localhost:3000`. Set the `PORT` env variable to change it.
78
+
79
+ ---
80
+
81
+ ## Script tag options
82
+
83
+ | Attribute | Values | Default | Description |
84
+ |-----------|--------|---------|-------------|
85
+ | `data-site-id` | any string | `demo` | Your unique publisher identifier |
86
+ | `data-api` | URL | — | Your Lens server endpoint for analytics |
87
+ | `data-mode` | `emphasis` · `collapse` · `both` · `off` | `emphasis` | How to adapt presentation |
88
+ | `data-debug` | `true` · `false` | `false` | Show relevance scores on each section |
89
+
90
+ **Modes:**
91
+ - `emphasis` — subtle left accent on highly relevant sections
92
+ - `collapse` — folds low-relevance sections to 2 lines with "Show more"
93
+ - `both` — emphasis on high, collapse on low
94
+ - `off` — analytics only, no visual changes
95
+
96
+ ---
97
+
98
+ ## Dashboard
99
+
100
+ The dashboard shows what your readers actually engage with — not just pageviews.
101
+
102
+ ```
103
+ http://localhost:3000/dashboard?site=YOUR_SITE_ID
104
+ ```
105
+
106
+ **What's included:**
107
+ - Section engagement ranked by average dwell time
108
+ - **Section interest heatmap** — sections in article order, coloured cold→hot by attention
109
+ - The gap between word count and actual attention (the insight that drives editorial decisions)
110
+ - Scroll depth distribution
111
+ - Top pages by traffic
112
+
113
+ ---
114
+
115
+ ## How the interest profile builds
116
+
117
+ The profile is entirely local — it never leaves the visitor's browser.
118
+
119
+ | Signal | Weight | Why |
120
+ |--------|--------|-----|
121
+ | Re-read (scrolled back) | 1.0 | Strongest signal — deliberate |
122
+ | Clipboard copy | 1.0 | User found it worth saving |
123
+ | Dwell > 5s | 0.5 | Moderate — attention held |
124
+ | Slow scroll velocity | 0.2 | Weak — ambiguous |
125
+
126
+ Keywords from engaged sections are weighted using an exponential moving average (EWMA). The top 120 keywords form the profile. No topic categories are pre-defined — the profile emerges from what each person reads.
127
+
128
+ Adaptation doesn't start until visit 2. The first visit is silent — building the profile — so there's no false adaptation before the system has signal.
129
+
130
+ ---
131
+
132
+ ## Privacy model
133
+
134
+ | Data | Where it lives | Sent to server? |
135
+ |------|----------------|-----------------|
136
+ | Visitor interest profile | `localStorage` only | Never |
137
+ | Section dwell times | Analytics server (aggregate) | Yes — anonymous, no user identity |
138
+ | Scroll depth | Analytics server | Yes — anonymous |
139
+ | Page path | Server stores path only | Yes — no query string, no user linkage |
140
+ | IP address | Not recorded by Lens | Arrives as normal HTTP traffic |
141
+ | Device fingerprint | Never collected | — |
142
+ | Cross-site tracking | Impossible — `localStorage` is same-origin | — |
143
+
144
+ GDPR-compatible for analytics use without a consent banner (no personal data collected). If you enable visual adaptation, check your jurisdiction's requirements for disclosures about personalised content.
145
+
146
+ ---
147
+
148
+ ## Build the npm package
149
+
150
+ ```bash
151
+ npm run build # produces dist/lens.umd.js + dist/lens.esm.js
152
+ npm publish --dry-run # verify what's included
153
+ npm publish --access public
154
+ ```
155
+
156
+ ---
157
+
158
+ ## Deploy to production
159
+
160
+ Standard Node.js/Express. Deploy to Railway, Render, Fly.io, or any VPS:
161
+
162
+ ```bash
163
+ PORT=3000 node server/index.js
164
+ ```
165
+
166
+ Then point `data-api` in your script tag to your deployed URL.
167
+
168
+ ---
169
+
170
+ ## Roadmap
171
+
172
+ - [x] Section interest heatmap in dashboard
173
+ - [x] WordPress plugin
174
+ - [x] Multi-format demo (tech, news, magazine, tutorial)
175
+ - [x] npm package (`@zsolt/lens`)
176
+ - [ ] Multi-page interest graph (track topics across your whole publication)
177
+ - [ ] A/B mode — adapted vs. non-adapted to 50/50 of visitors, measure engagement delta
178
+ - [ ] Weekly publisher digest — "what your readers actually cared about this week"
179
+ - [ ] Ghost / Webflow plugins
180
+ - [ ] Optional AI-generated per-reader summary (publisher provides API key)
@@ -0,0 +1 @@
1
+ (function(f,s){"use strict";var x=s.currentScript,k=x.getAttribute("data-site-id")||"demo",T=(x.getAttribute("data-api")||"http://localhost:3001").replace(/\/$/,""),D=x.getAttribute("data-api-key")||null,_=x.getAttribute("data-mode")||"emphasis",v=x.getAttribute("data-debug")==="true",K=x.getAttribute("data-onboard")==="true",J=x.getAttribute("data-ab")==="true",C="lens_ab_v1";function W(){if(!J)return null;try{var e=JSON.parse(localStorage.getItem(C))||{};return e[k]||(e[k]=Math.random()<.5?"adapted":"control",localStorage.setItem(C,JSON.stringify(e))),e[k]}catch{return"adapted"}}var I=W(),N="lens_profile_v1";function A(){try{return JSON.parse(localStorage.getItem(N))||P()}catch{return P()}}function P(){return{topics:{},visits:0,lastSeen:null,style:{prefersDetail:.5,prefersTechnical:.5}}}function E(e){e.style||(e.style={prefersDetail:.5,prefersTechnical:.5}),e.style.prefersDetail==null&&(e.style.prefersDetail=.5),e.style.prefersTechnical==null&&(e.style.prefersTechnical=.5)}function j(e){try{localStorage.setItem(N,JSON.stringify(e))}catch{}}var R=["article",'[role="main"]',"main",".article-content",".post-content",".entry-content",".article-body","#content","#main-content","#article"];function G(){for(var e=0;e<R.length;e++)try{var n=s.querySelector(R[e]);if(n&&(n.innerText||"").length>400)return n}catch{}var r=null,i=0;return s.querySelectorAll("div, section").forEach(function(t){var l=Array.from(t.querySelectorAll("a")).reduce(function(u,g){return u+(g.innerText||"").length},0),a=(t.innerText||"").length-l*2;a>i&&a>300&&(i=a,r=t)}),r||s.body}function U(){var e=G(),n=e.querySelectorAll("p, h1, h2, h3, h4, h5, h6, li, blockquote"),r=[],i=0;return n.forEach(function(t){var l=(t.textContent||"").trim();if(!(l.length<25)){for(var a=t.parentElement;a;){var u=(a.tagName||"").toLowerCase();if(u==="nav"||u==="footer"||u==="header")return;a=a.parentElement}var g="lens-"+i++,y=l.substring(0,60).replace(/\s+/g," ");t.setAttribute("data-lens-id",g),t.setAttribute("data-lens-tag",t.tagName.toLowerCase()),t.setAttribute("data-lens-preview",y),r.push({id:g,el:t,text:l.substring(0,500),preview:y,score:.5})}}),r}var F=new Set(["about","after","again","also","another","because","been","before","being","between","both","came","come","could","does","doing","during","each","either","every","found","from","going","have","having","here","itself","just","like","make","many","might","more","most","much","must","never","next","none","nothing","often","only","other","over","same","should","since","some","such","than","that","their","them","then","there","these","they","this","those","through","time","under","until","very","well","were","what","when","where","which","while","will","with","within","would","your","into","upon","even","still","where","however","though","already","always","around","whether","without","simply","really","quite","rather","several","something","anything","everything","nothing","someone","anyone","everyone","first","second","third","last","next","previous","large","small","high","long","short","new","old","good","great","little","right","left","early","late","general","specific","different","similar","important","significant","particular","certain","possible","available","according","including","following","required","used","using","based","given","known","called","named","made","said","well","also","very"]);function L(e){return e.toLowerCase().replace(/[^a-z0-9\s]/g," ").split(/\s+/).filter(function(n){return n.length>4&&!F.has(n)})}function Q(e,n){var r=n.topics||{},i=Object.keys(r);if(i.length<3)return .5;var t=e.toLowerCase(),l=new Set(L(t)),a=0,u=0,g=0;if(i.forEach(function(o){var d=r[o];u+=d,l.has(o)&&(g+=d,a+=1)}),u===0)return .5;var y=g/Math.max(u*.15,.01);return Math.min(1,Math.max(0,y))}function z(){if(!s.getElementById("__lens_sdk_styles__")){var e=s.createElement("style");e.id="__lens_sdk_styles__",e.textContent='[data-lens-id]{transition:opacity .3s ease,border-left .3s ease}[data-lens-high]{border-left:3px solid rgba(99,102,241,.5)!important;padding-left:14px!important}[data-lens-low]{opacity:.45}.lens-collapsed{position:relative;max-height:3.8em;overflow:hidden}.lens-collapsed::after{content:"";position:absolute;bottom:0;left:0;right:0;height:36px;background:linear-gradient(transparent,rgba(255,255,255,.97));pointer-events:none}.lens-expand{display:inline-block;margin:3px 0 10px;font-size:12px;color:#6366f1;background:none;border:none;cursor:pointer;padding:0;text-decoration:underline;text-underline-offset:2px}.lens-expand:hover{color:#4f46e5}[data-lens-debug]::before{content:"Lens " attr(data-lens-debug);display:inline-block;font-size:9px;background:rgba(99,102,241,.12);color:#6366f1;border-radius:3px;padding:1px 6px;margin-right:6px;font-family:monospace;vertical-align:middle}.lens-rewrite-badge{display:inline-block;font-size:9px;background:rgba(16,185,129,.15);color:#059669;border-radius:3px;padding:1px 6px;margin-right:6px;font-family:monospace;vertical-align:middle}',s.head.appendChild(e)}}function S(e,n,r){if(_!=="off"&&!(Object.keys(n.topics).length<3)){E(n);var i=n.style.prefersDetail;z(),e.forEach(function(t){v&&t.el.setAttribute("data-lens-debug",Math.round(t.score*100)+"%"),t.score>=.55?t.el.setAttribute("data-lens-high",""):t.score<.25&&i>.65?t.el.setAttribute("data-lens-low",""):t.score<.25&&(_==="collapse"||_==="both")||t.score<.4&&i<.35&&_==="emphasis"?q(t.el,r):t.score<.25&&_==="emphasis"&&t.el.setAttribute("data-lens-low","")})}}function q(e,n){if(!((e.innerText||"").length<80)){e.classList.add("lens-collapsed");var r=s.createElement("button");r.className="lens-expand",r.textContent="Show more \u2193",r.addEventListener("click",function(){e.classList.remove("lens-collapsed"),r.remove(),n&&n.push({type:"expand",sectionId:e.getAttribute("data-lens-id"),words:(e.textContent||"").split(/\s+/).length})}),e.parentNode.insertBefore(r,e.nextSibling)}}function M(e,n){var r={},i={},t=new Set,l=n,a=[],u=f.scrollY,g=Date.now(),y=null,o=new IntersectionObserver(function(p){p.forEach(function(m){var c=m.target.getAttribute("data-lens-id");if(c){if(m.isIntersecting)r[c]=Date.now(),t.has(c)?l.push({type:"reread",sectionId:c,weight:1}):t.add(c);else if(r[c]){var b=(Date.now()-r[c])/1e3;i[c]=(i[c]||0)+b,delete r[c],b>2.5&&l.push({type:"dwell",sectionId:c,value:b,weight:.5})}}})},{threshold:.5});e.forEach(function(p){o.observe(p.el)}),f.addEventListener("scroll",function(){var p=Date.now(),m=Math.abs(f.scrollY-u),c=p-g;c>0&&m>0&&m/c<.35&&(clearTimeout(y),y=setTimeout(function(){for(var b=0;b<e.length;b++){var w=e[b].el.getBoundingClientRect();if(w.top>=0&&w.top<f.innerHeight*.5){l.push({type:"slowscroll",sectionId:e[b].id,weight:.2});break}}},500)),a.push({y:f.scrollY,t:p}),a.length>40&&a.shift(),u=f.scrollY,g=p},{passive:!0}),s.addEventListener("copy",function(){var p=f.getSelection();if(!(!p||!p.toString())){var m=p.toString().substring(0,30);e.forEach(function(c){c.el.textContent.indexOf(m)!==-1&&l.push({type:"copy",sectionId:c.id,weight:1})})}}),f.addEventListener("beforeunload",function(){Y(l,i,a,e)});var d;function h(){clearTimeout(d),d=setTimeout(function(){Y(l.splice(0),i,a,e)},9e4)}s.addEventListener("scroll",h,{passive:!0}),s.addEventListener("click",h,{passive:!0}),h()}var V=new Set(["api","sdk","latency","bandwidth","architecture","protocol","database","server","algorithm","runtime","cache","deploy","endpoint","throughput","inference","parameter","vector","embedding","quantization","transformer","kernel","compiler","binary","packet","token","benchmark","framework","library","repository","pipeline","cluster","container","kubernetes","docker"]);function Y(e,n,r,i){var t=A();E(t);var l=r.reduce(function(o,d){return Math.max(o,d.y)},0),a=Math.max(1,s.body.scrollHeight-f.innerHeight),u=Math.min(1,l/a);e.forEach(function(o){if(o.type==="expand"){t.style.prefersDetail=Math.min(1,t.style.prefersDetail+.15);return}if(!(o.weight<.5)){var d=i.find(function(h){return h.id===o.sectionId});d&&L(d.text).forEach(function(h){t.topics[h]=Math.min(1,(t.topics[h]||0)*.95+o.weight*.05)})}}),Object.keys(n).forEach(function(o){var d=n[o];if(!(d<4)){var h=i.find(function(w){return w.id===o});if(h){var p=Math.min(1,d/30)*.3;L(h.text).forEach(function(w){t.topics[w]=Math.min(1,(t.topics[w]||0)*.97+p*.03)});var m=h.text.split(/\s+/).length;m>120&&d>=10?t.style.prefersDetail=Math.min(1,t.style.prefersDetail+.04):m>120&&d<2&&(t.style.prefersDetail=Math.max(0,t.style.prefersDetail-.03));var c=L(h.text),b=c.filter(function(w){return V.has(w)}).length;b>=2&&d>=6&&(t.style.prefersTechnical=Math.min(1,t.style.prefersTechnical+.04))}}});var g=Object.entries(t.topics).sort(function(o,d){return d[1]-o[1]});t.topics=Object.fromEntries(g.slice(0,150)),t.visits+=1,t.lastSeen=Date.now(),j(t);var y=JSON.stringify({siteId:k,apiKey:D,path:f.location.pathname,scrollDepth:Math.round(u*100),sid:X(),abGroup:I,sections:i.map(function(o){return{id:o.id,tag:o.el.getAttribute("data-lens-tag"),preview:o.preview,dwell:Math.round(n[o.id]||0),score:Math.round(o.score*100),words:o.text.split(/\s+/).length}})});navigator.sendBeacon&&navigator.sendBeacon(T+"/events",y)}function X(){var e=sessionStorage.getItem("_lens_sid");return e||(e=Math.random().toString(36).substring(2,11),sessionStorage.setItem("_lens_sid",e)),e}function O(e){K&&(e.style._explicit||e.visits>1||setTimeout(function(){if(E(e),e.style._explicit)return;var n=s.createElement("div");n.id="__lens_toast__",n.style.cssText=["position:fixed;bottom:24px;left:50%;transform:translateX(-50%)","background:#1e293b;color:#e2e8f0;border-radius:10px","padding:14px 18px;font-family:system-ui,sans-serif;font-size:13px","box-shadow:0 8px 28px rgba(0,0,0,.35);z-index:999999","display:flex;align-items:center;gap:12px;max-width:340px"].join(";"),n.innerHTML=['<span style="flex:1;line-height:1.4">Prefer the quick summary or full detail?</span>','<button id="__lens_concise__" style="background:#6366f1;color:#fff;border:none;border-radius:6px;padding:6px 12px;cursor:pointer;font-size:12px;font-weight:600;white-space:nowrap">Quick</button>','<button id="__lens_detail__" style="background:#334155;color:#e2e8f0;border:none;border-radius:6px;padding:6px 12px;cursor:pointer;font-size:12px;font-weight:600;white-space:nowrap">Full detail</button>','<button id="__lens_dismiss__" style="background:none;border:none;color:#64748b;cursor:pointer;font-size:16px;line-height:1;padding:0 2px" title="Dismiss">\xD7</button>'].join(""),s.body.appendChild(n);function r(i){var t=A();E(t),t.style.prefersDetail=i,t.style._explicit=!0,j(t),n.remove()}s.getElementById("__lens_concise__").addEventListener("click",function(){r(.2)}),s.getElementById("__lens_detail__").addEventListener("click",function(){r(.8)}),s.getElementById("__lens_dismiss__").addEventListener("click",function(){n.remove()})},6e4))}function $(e){var n=e.contentPreferences||{};return{topics:e.topics||{},visits:e.visits||0,lastSeen:Date.now(),style:{prefersDetail:n.prefersDetail!=null?n.prefersDetail:.5,prefersTechnical:n.prefersTechnical!=null?n.prefersTechnical:.5,prefersNarrative:n.prefersNarrative!=null?n.prefersNarrative:.5,prefersBullets:n.prefersBullets!=null?n.prefersBullets:.5}}}function Z(e,n){for(var r={},i=0;i<n.length;i++)r[n[i].id]=n[i];e.forEach(function(t){var l=r[t.id];if(l){var a=t.el;t.score=l.relevanceScore,a.style.transition="opacity 150ms ease",a.style.opacity="0",setTimeout(function(){if(a.innerHTML=l.content,a.setAttribute("data-lens-rewritten","true"),v){var u=s.createElement("span");u.className="lens-rewrite-badge",u.textContent="AI "+Math.round(l.relevanceScore*100)+"%",a.insertBefore(u,a.firstChild)}a.style.opacity="1"},160)}})}function ee(e,n,r){if(Object.keys(e.topics).length<3){v&&console.log("[Lens] rewrite: fewer than 3 topics \u2014 falling back to both mode"),S(n,e,r);return}if(!T){v&&console.log("[Lens] rewrite: no API_URL configured \u2014 falling back to both mode"),S(n,e,r);return}z();var i=JSON.stringify({siteId:k,path:f.location.pathname,sections:n.map(function(t){return{id:t.id,text:t.text}}),profile:{topics:e.topics,contentPreferences:e.style}});fetch(T+"/adapt",{method:"POST",headers:Object.assign({"content-type":"application/json"},D?{Authorization:"Bearer "+D}:{}),body:i}).then(function(t){if(t.status===503)return v&&console.log("[Lens] rewrite: server has no AI provider \u2014 falling back to both mode"),S(n,e,r),null;if(!t.ok)throw new Error("HTTP "+t.status);return t.json()}).then(function(t){if(t){if(!t.sections||!t.sections.length){v&&console.log("[Lens] rewrite: empty response \u2014 falling back to both mode"),S(n,e,r);return}v&&console.log("[Lens] rewrite: applying "+t.sections.length+" rewritten sections"),Z(n,t.sections)}}).catch(function(t){v&&console.warn("[Lens] rewrite error ("+t.message+") \u2014 falling back to both mode"),S(n,e,r)})}function B(e){E(e);var n=U();if(!(n.length<3)){n.forEach(function(i){i.score=Q(i.text,e)});var r=[];if(I==="control"){v&&console.log("[Lens] A/B group: control \u2014 tracking only, no adaptation"),M(n,r),O(e);return}if(v&&I==="adapted"&&console.log("[Lens] A/B group: adapted"),_==="rewrite"){let i=function(){setTimeout(function(){ee(e,n,r),M(n,r),O(e)},300)};s.readyState==="loading"?s.addEventListener("DOMContentLoaded",i):i()}else S(n,e,r),M(n,r),O(e);v&&(console.log("[Lens] "+n.length+" sections. Topics: "+Object.keys(e.topics).length+". Visits: "+e.visits+". Mode: "+_),console.log("[Lens] Style \u2014 prefersDetail: "+e.style.prefersDetail.toFixed(2)+", prefersTechnical: "+e.style.prefersTechnical.toFixed(2)),console.table(n.slice(0,10).map(function(i){return{id:i.id,score:Math.round(i.score*100)+"%",preview:i.preview}})))}}function H(){if(f.__lens__&&f.__lens__.isBrowser){f.__lens__.getProfile().then(function(e){B($(e))}).catch(function(){B(A())});return}B(A())}s.readyState==="loading"?s.addEventListener("DOMContentLoaded",H):H()})(window,document);
@@ -0,0 +1 @@
1
+ var Lens=(()=>{(function(f,s){"use strict";var x=s.currentScript,k=x.getAttribute("data-site-id")||"demo",T=(x.getAttribute("data-api")||"http://localhost:3001").replace(/\/$/,""),D=x.getAttribute("data-api-key")||null,_=x.getAttribute("data-mode")||"emphasis",v=x.getAttribute("data-debug")==="true",K=x.getAttribute("data-onboard")==="true",J=x.getAttribute("data-ab")==="true",C="lens_ab_v1";function W(){if(!J)return null;try{var e=JSON.parse(localStorage.getItem(C))||{};return e[k]||(e[k]=Math.random()<.5?"adapted":"control",localStorage.setItem(C,JSON.stringify(e))),e[k]}catch{return"adapted"}}var I=W(),N="lens_profile_v1";function A(){try{return JSON.parse(localStorage.getItem(N))||P()}catch{return P()}}function P(){return{topics:{},visits:0,lastSeen:null,style:{prefersDetail:.5,prefersTechnical:.5}}}function E(e){e.style||(e.style={prefersDetail:.5,prefersTechnical:.5}),e.style.prefersDetail==null&&(e.style.prefersDetail=.5),e.style.prefersTechnical==null&&(e.style.prefersTechnical=.5)}function j(e){try{localStorage.setItem(N,JSON.stringify(e))}catch{}}var R=["article",'[role="main"]',"main",".article-content",".post-content",".entry-content",".article-body","#content","#main-content","#article"];function G(){for(var e=0;e<R.length;e++)try{var n=s.querySelector(R[e]);if(n&&(n.innerText||"").length>400)return n}catch{}var r=null,i=0;return s.querySelectorAll("div, section").forEach(function(t){var l=Array.from(t.querySelectorAll("a")).reduce(function(u,g){return u+(g.innerText||"").length},0),a=(t.innerText||"").length-l*2;a>i&&a>300&&(i=a,r=t)}),r||s.body}function U(){var e=G(),n=e.querySelectorAll("p, h1, h2, h3, h4, h5, h6, li, blockquote"),r=[],i=0;return n.forEach(function(t){var l=(t.textContent||"").trim();if(!(l.length<25)){for(var a=t.parentElement;a;){var u=(a.tagName||"").toLowerCase();if(u==="nav"||u==="footer"||u==="header")return;a=a.parentElement}var g="lens-"+i++,y=l.substring(0,60).replace(/\s+/g," ");t.setAttribute("data-lens-id",g),t.setAttribute("data-lens-tag",t.tagName.toLowerCase()),t.setAttribute("data-lens-preview",y),r.push({id:g,el:t,text:l.substring(0,500),preview:y,score:.5})}}),r}var F=new Set(["about","after","again","also","another","because","been","before","being","between","both","came","come","could","does","doing","during","each","either","every","found","from","going","have","having","here","itself","just","like","make","many","might","more","most","much","must","never","next","none","nothing","often","only","other","over","same","should","since","some","such","than","that","their","them","then","there","these","they","this","those","through","time","under","until","very","well","were","what","when","where","which","while","will","with","within","would","your","into","upon","even","still","where","however","though","already","always","around","whether","without","simply","really","quite","rather","several","something","anything","everything","nothing","someone","anyone","everyone","first","second","third","last","next","previous","large","small","high","long","short","new","old","good","great","little","right","left","early","late","general","specific","different","similar","important","significant","particular","certain","possible","available","according","including","following","required","used","using","based","given","known","called","named","made","said","well","also","very"]);function L(e){return e.toLowerCase().replace(/[^a-z0-9\s]/g," ").split(/\s+/).filter(function(n){return n.length>4&&!F.has(n)})}function Q(e,n){var r=n.topics||{},i=Object.keys(r);if(i.length<3)return .5;var t=e.toLowerCase(),l=new Set(L(t)),a=0,u=0,g=0;if(i.forEach(function(o){var d=r[o];u+=d,l.has(o)&&(g+=d,a+=1)}),u===0)return .5;var y=g/Math.max(u*.15,.01);return Math.min(1,Math.max(0,y))}function z(){if(!s.getElementById("__lens_sdk_styles__")){var e=s.createElement("style");e.id="__lens_sdk_styles__",e.textContent='[data-lens-id]{transition:opacity .3s ease,border-left .3s ease}[data-lens-high]{border-left:3px solid rgba(99,102,241,.5)!important;padding-left:14px!important}[data-lens-low]{opacity:.45}.lens-collapsed{position:relative;max-height:3.8em;overflow:hidden}.lens-collapsed::after{content:"";position:absolute;bottom:0;left:0;right:0;height:36px;background:linear-gradient(transparent,rgba(255,255,255,.97));pointer-events:none}.lens-expand{display:inline-block;margin:3px 0 10px;font-size:12px;color:#6366f1;background:none;border:none;cursor:pointer;padding:0;text-decoration:underline;text-underline-offset:2px}.lens-expand:hover{color:#4f46e5}[data-lens-debug]::before{content:"Lens " attr(data-lens-debug);display:inline-block;font-size:9px;background:rgba(99,102,241,.12);color:#6366f1;border-radius:3px;padding:1px 6px;margin-right:6px;font-family:monospace;vertical-align:middle}.lens-rewrite-badge{display:inline-block;font-size:9px;background:rgba(16,185,129,.15);color:#059669;border-radius:3px;padding:1px 6px;margin-right:6px;font-family:monospace;vertical-align:middle}',s.head.appendChild(e)}}function S(e,n,r){if(_!=="off"&&!(Object.keys(n.topics).length<3)){E(n);var i=n.style.prefersDetail;z(),e.forEach(function(t){v&&t.el.setAttribute("data-lens-debug",Math.round(t.score*100)+"%"),t.score>=.55?t.el.setAttribute("data-lens-high",""):t.score<.25&&i>.65?t.el.setAttribute("data-lens-low",""):t.score<.25&&(_==="collapse"||_==="both")||t.score<.4&&i<.35&&_==="emphasis"?q(t.el,r):t.score<.25&&_==="emphasis"&&t.el.setAttribute("data-lens-low","")})}}function q(e,n){if(!((e.innerText||"").length<80)){e.classList.add("lens-collapsed");var r=s.createElement("button");r.className="lens-expand",r.textContent="Show more \u2193",r.addEventListener("click",function(){e.classList.remove("lens-collapsed"),r.remove(),n&&n.push({type:"expand",sectionId:e.getAttribute("data-lens-id"),words:(e.textContent||"").split(/\s+/).length})}),e.parentNode.insertBefore(r,e.nextSibling)}}function M(e,n){var r={},i={},t=new Set,l=n,a=[],u=f.scrollY,g=Date.now(),y=null,o=new IntersectionObserver(function(p){p.forEach(function(m){var c=m.target.getAttribute("data-lens-id");if(c){if(m.isIntersecting)r[c]=Date.now(),t.has(c)?l.push({type:"reread",sectionId:c,weight:1}):t.add(c);else if(r[c]){var b=(Date.now()-r[c])/1e3;i[c]=(i[c]||0)+b,delete r[c],b>2.5&&l.push({type:"dwell",sectionId:c,value:b,weight:.5})}}})},{threshold:.5});e.forEach(function(p){o.observe(p.el)}),f.addEventListener("scroll",function(){var p=Date.now(),m=Math.abs(f.scrollY-u),c=p-g;c>0&&m>0&&m/c<.35&&(clearTimeout(y),y=setTimeout(function(){for(var b=0;b<e.length;b++){var w=e[b].el.getBoundingClientRect();if(w.top>=0&&w.top<f.innerHeight*.5){l.push({type:"slowscroll",sectionId:e[b].id,weight:.2});break}}},500)),a.push({y:f.scrollY,t:p}),a.length>40&&a.shift(),u=f.scrollY,g=p},{passive:!0}),s.addEventListener("copy",function(){var p=f.getSelection();if(!(!p||!p.toString())){var m=p.toString().substring(0,30);e.forEach(function(c){c.el.textContent.indexOf(m)!==-1&&l.push({type:"copy",sectionId:c.id,weight:1})})}}),f.addEventListener("beforeunload",function(){Y(l,i,a,e)});var d;function h(){clearTimeout(d),d=setTimeout(function(){Y(l.splice(0),i,a,e)},9e4)}s.addEventListener("scroll",h,{passive:!0}),s.addEventListener("click",h,{passive:!0}),h()}var V=new Set(["api","sdk","latency","bandwidth","architecture","protocol","database","server","algorithm","runtime","cache","deploy","endpoint","throughput","inference","parameter","vector","embedding","quantization","transformer","kernel","compiler","binary","packet","token","benchmark","framework","library","repository","pipeline","cluster","container","kubernetes","docker"]);function Y(e,n,r,i){var t=A();E(t);var l=r.reduce(function(o,d){return Math.max(o,d.y)},0),a=Math.max(1,s.body.scrollHeight-f.innerHeight),u=Math.min(1,l/a);e.forEach(function(o){if(o.type==="expand"){t.style.prefersDetail=Math.min(1,t.style.prefersDetail+.15);return}if(!(o.weight<.5)){var d=i.find(function(h){return h.id===o.sectionId});d&&L(d.text).forEach(function(h){t.topics[h]=Math.min(1,(t.topics[h]||0)*.95+o.weight*.05)})}}),Object.keys(n).forEach(function(o){var d=n[o];if(!(d<4)){var h=i.find(function(w){return w.id===o});if(h){var p=Math.min(1,d/30)*.3;L(h.text).forEach(function(w){t.topics[w]=Math.min(1,(t.topics[w]||0)*.97+p*.03)});var m=h.text.split(/\s+/).length;m>120&&d>=10?t.style.prefersDetail=Math.min(1,t.style.prefersDetail+.04):m>120&&d<2&&(t.style.prefersDetail=Math.max(0,t.style.prefersDetail-.03));var c=L(h.text),b=c.filter(function(w){return V.has(w)}).length;b>=2&&d>=6&&(t.style.prefersTechnical=Math.min(1,t.style.prefersTechnical+.04))}}});var g=Object.entries(t.topics).sort(function(o,d){return d[1]-o[1]});t.topics=Object.fromEntries(g.slice(0,150)),t.visits+=1,t.lastSeen=Date.now(),j(t);var y=JSON.stringify({siteId:k,apiKey:D,path:f.location.pathname,scrollDepth:Math.round(u*100),sid:X(),abGroup:I,sections:i.map(function(o){return{id:o.id,tag:o.el.getAttribute("data-lens-tag"),preview:o.preview,dwell:Math.round(n[o.id]||0),score:Math.round(o.score*100),words:o.text.split(/\s+/).length}})});navigator.sendBeacon&&navigator.sendBeacon(T+"/events",y)}function X(){var e=sessionStorage.getItem("_lens_sid");return e||(e=Math.random().toString(36).substring(2,11),sessionStorage.setItem("_lens_sid",e)),e}function O(e){K&&(e.style._explicit||e.visits>1||setTimeout(function(){if(E(e),e.style._explicit)return;var n=s.createElement("div");n.id="__lens_toast__",n.style.cssText=["position:fixed;bottom:24px;left:50%;transform:translateX(-50%)","background:#1e293b;color:#e2e8f0;border-radius:10px","padding:14px 18px;font-family:system-ui,sans-serif;font-size:13px","box-shadow:0 8px 28px rgba(0,0,0,.35);z-index:999999","display:flex;align-items:center;gap:12px;max-width:340px"].join(";"),n.innerHTML=['<span style="flex:1;line-height:1.4">Prefer the quick summary or full detail?</span>','<button id="__lens_concise__" style="background:#6366f1;color:#fff;border:none;border-radius:6px;padding:6px 12px;cursor:pointer;font-size:12px;font-weight:600;white-space:nowrap">Quick</button>','<button id="__lens_detail__" style="background:#334155;color:#e2e8f0;border:none;border-radius:6px;padding:6px 12px;cursor:pointer;font-size:12px;font-weight:600;white-space:nowrap">Full detail</button>','<button id="__lens_dismiss__" style="background:none;border:none;color:#64748b;cursor:pointer;font-size:16px;line-height:1;padding:0 2px" title="Dismiss">\xD7</button>'].join(""),s.body.appendChild(n);function r(i){var t=A();E(t),t.style.prefersDetail=i,t.style._explicit=!0,j(t),n.remove()}s.getElementById("__lens_concise__").addEventListener("click",function(){r(.2)}),s.getElementById("__lens_detail__").addEventListener("click",function(){r(.8)}),s.getElementById("__lens_dismiss__").addEventListener("click",function(){n.remove()})},6e4))}function $(e){var n=e.contentPreferences||{};return{topics:e.topics||{},visits:e.visits||0,lastSeen:Date.now(),style:{prefersDetail:n.prefersDetail!=null?n.prefersDetail:.5,prefersTechnical:n.prefersTechnical!=null?n.prefersTechnical:.5,prefersNarrative:n.prefersNarrative!=null?n.prefersNarrative:.5,prefersBullets:n.prefersBullets!=null?n.prefersBullets:.5}}}function Z(e,n){for(var r={},i=0;i<n.length;i++)r[n[i].id]=n[i];e.forEach(function(t){var l=r[t.id];if(l){var a=t.el;t.score=l.relevanceScore,a.style.transition="opacity 150ms ease",a.style.opacity="0",setTimeout(function(){if(a.innerHTML=l.content,a.setAttribute("data-lens-rewritten","true"),v){var u=s.createElement("span");u.className="lens-rewrite-badge",u.textContent="AI "+Math.round(l.relevanceScore*100)+"%",a.insertBefore(u,a.firstChild)}a.style.opacity="1"},160)}})}function ee(e,n,r){if(Object.keys(e.topics).length<3){v&&console.log("[Lens] rewrite: fewer than 3 topics \u2014 falling back to both mode"),S(n,e,r);return}if(!T){v&&console.log("[Lens] rewrite: no API_URL configured \u2014 falling back to both mode"),S(n,e,r);return}z();var i=JSON.stringify({siteId:k,path:f.location.pathname,sections:n.map(function(t){return{id:t.id,text:t.text}}),profile:{topics:e.topics,contentPreferences:e.style}});fetch(T+"/adapt",{method:"POST",headers:Object.assign({"content-type":"application/json"},D?{Authorization:"Bearer "+D}:{}),body:i}).then(function(t){if(t.status===503)return v&&console.log("[Lens] rewrite: server has no AI provider \u2014 falling back to both mode"),S(n,e,r),null;if(!t.ok)throw new Error("HTTP "+t.status);return t.json()}).then(function(t){if(t){if(!t.sections||!t.sections.length){v&&console.log("[Lens] rewrite: empty response \u2014 falling back to both mode"),S(n,e,r);return}v&&console.log("[Lens] rewrite: applying "+t.sections.length+" rewritten sections"),Z(n,t.sections)}}).catch(function(t){v&&console.warn("[Lens] rewrite error ("+t.message+") \u2014 falling back to both mode"),S(n,e,r)})}function B(e){E(e);var n=U();if(!(n.length<3)){n.forEach(function(i){i.score=Q(i.text,e)});var r=[];if(I==="control"){v&&console.log("[Lens] A/B group: control \u2014 tracking only, no adaptation"),M(n,r),O(e);return}if(v&&I==="adapted"&&console.log("[Lens] A/B group: adapted"),_==="rewrite"){let i=function(){setTimeout(function(){ee(e,n,r),M(n,r),O(e)},300)};s.readyState==="loading"?s.addEventListener("DOMContentLoaded",i):i()}else S(n,e,r),M(n,r),O(e);v&&(console.log("[Lens] "+n.length+" sections. Topics: "+Object.keys(e.topics).length+". Visits: "+e.visits+". Mode: "+_),console.log("[Lens] Style \u2014 prefersDetail: "+e.style.prefersDetail.toFixed(2)+", prefersTechnical: "+e.style.prefersTechnical.toFixed(2)),console.table(n.slice(0,10).map(function(i){return{id:i.id,score:Math.round(i.score*100)+"%",preview:i.preview}})))}}function H(){if(f.__lens__&&f.__lens__.isBrowser){f.__lens__.getProfile().then(function(e){B($(e))}).catch(function(){B(A())});return}B(A())}s.readyState==="loading"?s.addEventListener("DOMContentLoaded",H):H()})(window,document);})();
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@zsoltcsaszti/lens",
3
+ "version": "0.2.0",
4
+ "description": "Reader intelligence for publishers. One script tag. Zero tracking cookies.",
5
+ "main": "dist/lens.umd.js",
6
+ "module": "dist/lens.esm.js",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/lens.esm.js",
10
+ "require": "./dist/lens.umd.js",
11
+ "default": "./dist/lens.umd.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist/",
16
+ "sdk/lens.js",
17
+ "README.md"
18
+ ],
19
+ "sideEffects": true,
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/zsoltcsaszti/lens-sdk"
23
+ },
24
+ "license": "MIT",
25
+ "keywords": [
26
+ "analytics",
27
+ "publisher",
28
+ "reading",
29
+ "engagement",
30
+ "privacy",
31
+ "reader-intelligence"
32
+ ],
33
+ "scripts": {
34
+ "build": "node build.js",
35
+ "start": "node server/index.js",
36
+ "dev": "node --watch server/index.js",
37
+ "prepublishOnly": "npm run build"
38
+ },
39
+ "devDependencies": {
40
+ "esbuild": "^0.21.5"
41
+ },
42
+ "dependencies": {
43
+ "cors": "^2.8.5",
44
+ "express": "^4.19.2"
45
+ }
46
+ }
package/sdk/lens.js ADDED
@@ -0,0 +1,660 @@
1
+ /**
2
+ * Lens SDK v0.2
3
+ * Drop-in reading intelligence for any publisher.
4
+ *
5
+ * <script src="lens.js"
6
+ * data-site-id="your_site_id"
7
+ * data-api="https://your-lens-server.com"
8
+ * data-mode="emphasis" <!-- emphasis | collapse | both | rewrite | off -->
9
+ * data-ab="true"> <!-- enable A/B test: 50% adapted, 50% control -->
10
+ * </script>
11
+ */
12
+ (function (w, d) {
13
+ 'use strict';
14
+
15
+ // ── Config ─────────────────────────────────────────────────────────────
16
+ var script = d.currentScript;
17
+ var SITE_ID = script.getAttribute('data-site-id') || 'demo';
18
+ var API_URL = (script.getAttribute('data-api') || 'http://localhost:3001').replace(/\/$/, '');
19
+ var API_KEY = script.getAttribute('data-api-key') || null;
20
+ var MODE = script.getAttribute('data-mode') || 'emphasis';
21
+ var DEBUG = script.getAttribute('data-debug') === 'true';
22
+ var ONBOARD = script.getAttribute('data-onboard') === 'true';
23
+ var AB = script.getAttribute('data-ab') === 'true';
24
+
25
+ // ── A/B assignment ──────────────────────────────────────────────────────
26
+ var AB_STORE_KEY = 'lens_ab_v1';
27
+
28
+ function getAbGroup() {
29
+ if (!AB) return null;
30
+ try {
31
+ var store = JSON.parse(localStorage.getItem(AB_STORE_KEY)) || {};
32
+ if (!store[SITE_ID]) {
33
+ store[SITE_ID] = Math.random() < 0.5 ? 'adapted' : 'control';
34
+ localStorage.setItem(AB_STORE_KEY, JSON.stringify(store));
35
+ }
36
+ return store[SITE_ID];
37
+ } catch (_) { return 'adapted'; }
38
+ }
39
+
40
+ var AB_GROUP = getAbGroup(); // sticky for this page session
41
+
42
+ // ── Local profile ───────────────────────────────────────────────────────
43
+ var STORE_KEY = 'lens_profile_v1';
44
+
45
+ function loadProfile() {
46
+ try { return JSON.parse(localStorage.getItem(STORE_KEY)) || freshProfile(); }
47
+ catch (_) { return freshProfile(); }
48
+ }
49
+ function freshProfile() {
50
+ return { topics: {}, visits: 0, lastSeen: null, style: { prefersDetail: 0.5, prefersTechnical: 0.5 } };
51
+ }
52
+
53
+ function ensureStyle(p) {
54
+ if (!p.style) p.style = { prefersDetail: 0.5, prefersTechnical: 0.5 };
55
+ if (p.style.prefersDetail == null) p.style.prefersDetail = 0.5;
56
+ if (p.style.prefersTechnical == null) p.style.prefersTechnical = 0.5;
57
+ }
58
+ function saveProfile(p) {
59
+ try { localStorage.setItem(STORE_KEY, JSON.stringify(p)); } catch (_) {}
60
+ }
61
+
62
+ // ── Section detection ───────────────────────────────────────────────────
63
+ var AREA_SELECTORS = [
64
+ 'article', '[role="main"]', 'main',
65
+ '.article-content', '.post-content', '.entry-content', '.article-body',
66
+ '#content', '#main-content', '#article'
67
+ ];
68
+
69
+ function findContentArea() {
70
+ for (var i = 0; i < AREA_SELECTORS.length; i++) {
71
+ try {
72
+ var el = d.querySelector(AREA_SELECTORS[i]);
73
+ if (el && (el.innerText || '').length > 400) return el;
74
+ } catch (_) {}
75
+ }
76
+ var best = null, bestScore = 0;
77
+ d.querySelectorAll('div, section').forEach(function (el) {
78
+ var linkLen = Array.from(el.querySelectorAll('a'))
79
+ .reduce(function (s, a) { return s + (a.innerText || '').length; }, 0);
80
+ var score = (el.innerText || '').length - linkLen * 2;
81
+ if (score > bestScore && score > 300) { bestScore = score; best = el; }
82
+ });
83
+ return best || d.body;
84
+ }
85
+
86
+ function detectSections() {
87
+ var area = findContentArea();
88
+ var elements = area.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote');
89
+ var sections = [];
90
+ var idx = 0;
91
+
92
+ elements.forEach(function (el) {
93
+ var text = (el.textContent || '').trim();
94
+ if (text.length < 25) return;
95
+
96
+ // Skip nav/header/footer parents
97
+ var p = el.parentElement;
98
+ while (p) {
99
+ var tag = (p.tagName || '').toLowerCase();
100
+ if (tag === 'nav' || tag === 'footer' || tag === 'header') return;
101
+ p = p.parentElement;
102
+ }
103
+
104
+ var id = 'lens-' + idx++;
105
+ var preview = text.substring(0, 60).replace(/\s+/g, ' '); // human-readable label
106
+ el.setAttribute('data-lens-id', id);
107
+ el.setAttribute('data-lens-tag', el.tagName.toLowerCase());
108
+ el.setAttribute('data-lens-preview', preview);
109
+
110
+ sections.push({ id: id, el: el, text: text.substring(0, 500), preview: preview, score: 0.5 });
111
+ });
112
+
113
+ return sections;
114
+ }
115
+
116
+ // ── Stopwords ───────────────────────────────────────────────────────────
117
+ var STOPWORDS = new Set([
118
+ 'about','after','again','also','another','because','been','before','being',
119
+ 'between','both','came','come','could','does','doing','during','each',
120
+ 'either','every','found','from','going','have','having','here','itself',
121
+ 'just','like','make','many','might','more','most','much','must','never',
122
+ 'next','none','nothing','often','only','other','over','same','should',
123
+ 'since','some','such','than','that','their','them','then','there','these',
124
+ 'they','this','those','through','time','under','until','very','well',
125
+ 'were','what','when','where','which','while','will','with','within',
126
+ 'would','your','into','upon','even','still','where','however','though',
127
+ 'already','always','around','whether','without','simply','really','quite',
128
+ 'rather','several','something','anything','everything','nothing','someone',
129
+ 'anyone','everyone','first','second','third','last','next','previous',
130
+ 'large','small','high','long','short','new','old','good','great','little',
131
+ 'right','left','early','late','general','specific','different','similar',
132
+ 'important','significant','particular','certain','possible','available',
133
+ 'according','including','following','required','used','using','based',
134
+ 'given','known','called','named','made','said','well','also','very'
135
+ ]);
136
+
137
+ function extractKeywords(text) {
138
+ return text
139
+ .toLowerCase()
140
+ .replace(/[^a-z0-9\s]/g, ' ')
141
+ .split(/\s+/)
142
+ .filter(function (w) { return w.length > 4 && !STOPWORDS.has(w); });
143
+ }
144
+
145
+ // ── Scoring ─────────────────────────────────────────────────────────────
146
+ // Lower the minimum topics threshold — adapt sooner, improve over time
147
+ function scoreSection(text, profile) {
148
+ var topics = profile.topics || {};
149
+ var topicKeys = Object.keys(topics);
150
+ if (topicKeys.length < 3) return 0.5; // truly no data yet
151
+
152
+ var lower = text.toLowerCase();
153
+ var sectionKws = new Set(extractKeywords(lower));
154
+ var matched = 0;
155
+ var totalW = 0;
156
+ var matchW = 0;
157
+
158
+ topicKeys.forEach(function (kw) {
159
+ var w = topics[kw];
160
+ totalW += w;
161
+ if (sectionKws.has(kw)) {
162
+ matchW += w;
163
+ matched += 1;
164
+ }
165
+ });
166
+
167
+ if (totalW === 0) return 0.5;
168
+
169
+ // Weighted overlap ratio — how much of the user's interest weight is present
170
+ var raw = matchW / Math.max(totalW * 0.15, 0.01);
171
+ return Math.min(1, Math.max(0, raw));
172
+ }
173
+
174
+ // ── CSS adaptation (never touches text) ────────────────────────────────
175
+ function injectStyles() {
176
+ if (d.getElementById('__lens_sdk_styles__')) return;
177
+ var s = d.createElement('style');
178
+ s.id = '__lens_sdk_styles__';
179
+ s.textContent =
180
+ '[data-lens-id]{transition:opacity .3s ease,border-left .3s ease}' +
181
+ '[data-lens-high]{border-left:3px solid rgba(99,102,241,.5)!important;padding-left:14px!important}' +
182
+ '[data-lens-low]{opacity:.45}' +
183
+ '.lens-collapsed{position:relative;max-height:3.8em;overflow:hidden}' +
184
+ '.lens-collapsed::after{content:"";position:absolute;bottom:0;left:0;right:0;height:36px;' +
185
+ 'background:linear-gradient(transparent,rgba(255,255,255,.97));pointer-events:none}' +
186
+ '.lens-expand{display:inline-block;margin:3px 0 10px;font-size:12px;color:#6366f1;' +
187
+ 'background:none;border:none;cursor:pointer;padding:0;text-decoration:underline;' +
188
+ 'text-underline-offset:2px}' +
189
+ '.lens-expand:hover{color:#4f46e5}' +
190
+ '[data-lens-debug]::before{content:"Lens " attr(data-lens-debug);display:inline-block;' +
191
+ 'font-size:9px;background:rgba(99,102,241,.12);color:#6366f1;border-radius:3px;' +
192
+ 'padding:1px 6px;margin-right:6px;font-family:monospace;vertical-align:middle}' +
193
+ '.lens-rewrite-badge{display:inline-block;font-size:9px;background:rgba(16,185,129,.15);' +
194
+ 'color:#059669;border-radius:3px;padding:1px 6px;margin-right:6px;' +
195
+ 'font-family:monospace;vertical-align:middle}';
196
+ d.head.appendChild(s);
197
+ }
198
+
199
+ function adapt(sections, profile, expandEvents) {
200
+ if (MODE === 'off') return;
201
+ if (Object.keys(profile.topics).length < 3) return;
202
+
203
+ ensureStyle(profile);
204
+ var detail = profile.style.prefersDetail;
205
+
206
+ injectStyles();
207
+
208
+ sections.forEach(function (s) {
209
+ if (DEBUG) s.el.setAttribute('data-lens-debug', Math.round(s.score * 100) + '%');
210
+
211
+ if (s.score >= 0.55) {
212
+ s.el.setAttribute('data-lens-high', '');
213
+ } else if (s.score < 0.25 && detail > 0.65) {
214
+ // Detail-preferring reader: de-emphasise rather than collapse
215
+ s.el.setAttribute('data-lens-low', '');
216
+ } else if (s.score < 0.25 && (MODE === 'collapse' || MODE === 'both')) {
217
+ collapseSection(s.el, expandEvents);
218
+ } else if (s.score < 0.4 && detail < 0.35 && MODE === 'emphasis') {
219
+ // Concise-preferring reader: collapse even in emphasis mode for low-relevance sections
220
+ collapseSection(s.el, expandEvents);
221
+ } else if (s.score < 0.25 && MODE === 'emphasis') {
222
+ s.el.setAttribute('data-lens-low', '');
223
+ }
224
+ });
225
+ }
226
+
227
+ function collapseSection(el, expandEvents) {
228
+ if ((el.innerText || '').length < 80) return;
229
+ el.classList.add('lens-collapsed');
230
+ var btn = d.createElement('button');
231
+ btn.className = 'lens-expand';
232
+ btn.textContent = 'Show more ↓';
233
+ btn.addEventListener('click', function () {
234
+ el.classList.remove('lens-collapsed');
235
+ btn.remove();
236
+ if (expandEvents) {
237
+ expandEvents.push({ type: 'expand', sectionId: el.getAttribute('data-lens-id'), words: (el.textContent || '').split(/\s+/).length });
238
+ }
239
+ });
240
+ el.parentNode.insertBefore(btn, el.nextSibling);
241
+ }
242
+
243
+ // ── Behavior tracking ───────────────────────────────────────────────────
244
+ function startTracking(sections, expandEvents) {
245
+ var dwellStart = {};
246
+ var dwellTotal = {};
247
+ var seen = new Set();
248
+ var events = expandEvents; // shared array — expand clicks land here too
249
+ var scrollLog = [];
250
+ var lastY = w.scrollY;
251
+ var lastT = Date.now();
252
+ var slowScrollTimer = null; // debounce slow-scroll signal
253
+
254
+ // IntersectionObserver: dwell + re-reads
255
+ var io = new IntersectionObserver(function (entries) {
256
+ entries.forEach(function (e) {
257
+ var id = e.target.getAttribute('data-lens-id');
258
+ if (!id) return;
259
+
260
+ if (e.isIntersecting) {
261
+ dwellStart[id] = Date.now();
262
+ if (seen.has(id)) {
263
+ events.push({ type: 'reread', sectionId: id, weight: 1.0 });
264
+ } else {
265
+ seen.add(id);
266
+ }
267
+ } else if (dwellStart[id]) {
268
+ var dwell = (Date.now() - dwellStart[id]) / 1000;
269
+ dwellTotal[id] = (dwellTotal[id] || 0) + dwell;
270
+ delete dwellStart[id];
271
+ if (dwell > 2.5) {
272
+ events.push({ type: 'dwell', sectionId: id, value: dwell, weight: 0.5 });
273
+ }
274
+ }
275
+ });
276
+ }, { threshold: 0.5 });
277
+
278
+ sections.forEach(function (s) { io.observe(s.el); });
279
+
280
+ // Scroll velocity — debounced to fire at most once per 500ms
281
+ w.addEventListener('scroll', function () {
282
+ var now = Date.now();
283
+ var dy = Math.abs(w.scrollY - lastY);
284
+ var dt = now - lastT;
285
+
286
+ if (dt > 0 && dy > 0 && dy / dt < 0.35) {
287
+ clearTimeout(slowScrollTimer);
288
+ slowScrollTimer = setTimeout(function () {
289
+ // Find the topmost visible section at the moment of slow scroll
290
+ for (var i = 0; i < sections.length; i++) {
291
+ var r = sections[i].el.getBoundingClientRect();
292
+ if (r.top >= 0 && r.top < w.innerHeight * 0.5) {
293
+ events.push({ type: 'slowscroll', sectionId: sections[i].id, weight: 0.2 });
294
+ break; // only one signal per slow-scroll event
295
+ }
296
+ }
297
+ }, 500);
298
+ }
299
+
300
+ scrollLog.push({ y: w.scrollY, t: now });
301
+ if (scrollLog.length > 40) scrollLog.shift();
302
+ lastY = w.scrollY;
303
+ lastT = now;
304
+ }, { passive: true });
305
+
306
+ // Clipboard copy
307
+ d.addEventListener('copy', function () {
308
+ var sel = w.getSelection();
309
+ if (!sel || !sel.toString()) return;
310
+ var prefix = sel.toString().substring(0, 30);
311
+ sections.forEach(function (s) {
312
+ if (s.el.textContent.indexOf(prefix) !== -1) {
313
+ events.push({ type: 'copy', sectionId: s.id, weight: 1.0 });
314
+ }
315
+ });
316
+ });
317
+
318
+ // Page leave
319
+ w.addEventListener('beforeunload', function () {
320
+ flush(events, dwellTotal, scrollLog, sections);
321
+ });
322
+
323
+ // Flush after 90s idle
324
+ var idleTimer;
325
+ function resetIdle() {
326
+ clearTimeout(idleTimer);
327
+ idleTimer = setTimeout(function () {
328
+ flush(events.splice(0), dwellTotal, scrollLog, sections);
329
+ }, 90000);
330
+ }
331
+ d.addEventListener('scroll', resetIdle, { passive: true });
332
+ d.addEventListener('click', resetIdle, { passive: true });
333
+ resetIdle();
334
+ }
335
+
336
+ var TECHNICAL_WORDS = new Set([
337
+ 'api','sdk','latency','bandwidth','architecture','protocol','database','server',
338
+ 'algorithm','runtime','cache','deploy','endpoint','throughput','inference',
339
+ 'parameter','vector','embedding','quantization','transformer','kernel',
340
+ 'compiler','binary','packet','token','benchmark','framework','library',
341
+ 'repository','pipeline','cluster','container','kubernetes','docker'
342
+ ]);
343
+
344
+ // ── Flush: update profile + report to server ────────────────────────────
345
+ function flush(events, dwellTotal, scrollLog, sections) {
346
+ var profile = loadProfile();
347
+ ensureStyle(profile);
348
+ var maxY = scrollLog.reduce(function (m, p) { return Math.max(m, p.y); }, 0);
349
+ var docH = Math.max(1, d.body.scrollHeight - w.innerHeight);
350
+ var scrollDepth = Math.min(1, maxY / docH);
351
+
352
+ // High-signal events → extract keywords from engaged sections
353
+ events.forEach(function (ev) {
354
+ if (ev.type === 'expand') {
355
+ // Reader explicitly expanded a collapsed section → strong detail signal
356
+ profile.style.prefersDetail = Math.min(1, profile.style.prefersDetail + 0.15);
357
+ return;
358
+ }
359
+ if (ev.weight < 0.5) return;
360
+ var section = sections.find(function (s) { return s.id === ev.sectionId; });
361
+ if (!section) return;
362
+ extractKeywords(section.text).forEach(function (kw) {
363
+ profile.topics[kw] = Math.min(1, (profile.topics[kw] || 0) * 0.95 + ev.weight * 0.05);
364
+ });
365
+ });
366
+
367
+ // Dwell time → keyword reinforcement + style inference
368
+ Object.keys(dwellTotal).forEach(function (sid) {
369
+ var dwell = dwellTotal[sid];
370
+ if (dwell < 4) return;
371
+ var section = sections.find(function (s) { return s.id === sid; });
372
+ if (!section) return;
373
+ var dw = Math.min(1, dwell / 30) * 0.3;
374
+
375
+ extractKeywords(section.text).forEach(function (kw) {
376
+ profile.topics[kw] = Math.min(1, (profile.topics[kw] || 0) * 0.97 + dw * 0.03);
377
+ });
378
+
379
+ // Style inference: long sections + high dwell → prefers detail
380
+ var wordCount = section.text.split(/\s+/).length;
381
+ if (wordCount > 120 && dwell >= 10) {
382
+ profile.style.prefersDetail = Math.min(1, profile.style.prefersDetail + 0.04);
383
+ } else if (wordCount > 120 && dwell < 2) {
384
+ // Skipped a long section → concise signal
385
+ profile.style.prefersDetail = Math.max(0, profile.style.prefersDetail - 0.03);
386
+ }
387
+
388
+ // Technical preference: dwell on sections with technical vocabulary
389
+ var kws = extractKeywords(section.text);
390
+ var techMatches = kws.filter(function(k) { return TECHNICAL_WORDS.has(k); }).length;
391
+ if (techMatches >= 2 && dwell >= 6) {
392
+ profile.style.prefersTechnical = Math.min(1, profile.style.prefersTechnical + 0.04);
393
+ }
394
+ });
395
+
396
+ // Prune to top 150 topics
397
+ var sorted = Object.entries(profile.topics).sort(function (a, b) { return b[1] - a[1]; });
398
+ profile.topics = Object.fromEntries(sorted.slice(0, 150));
399
+ profile.visits += 1;
400
+ profile.lastSeen = Date.now();
401
+ saveProfile(profile);
402
+
403
+ // Send anonymised data to server
404
+ // Include section preview text so the dashboard can show readable labels
405
+ var payload = JSON.stringify({
406
+ siteId: SITE_ID,
407
+ apiKey: API_KEY,
408
+ path: w.location.pathname,
409
+ scrollDepth: Math.round(scrollDepth * 100),
410
+ sid: getSessionId(),
411
+ abGroup: AB_GROUP,
412
+ sections: sections.map(function (s) {
413
+ return {
414
+ id: s.id,
415
+ tag: s.el.getAttribute('data-lens-tag'),
416
+ preview: s.preview, // first 60 chars — makes dashboard readable
417
+ dwell: Math.round(dwellTotal[s.id] || 0),
418
+ score: Math.round(s.score * 100),
419
+ words: s.text.split(/\s+/).length
420
+ };
421
+ })
422
+ });
423
+
424
+ if (navigator.sendBeacon) {
425
+ navigator.sendBeacon(API_URL + '/events', payload);
426
+ }
427
+ }
428
+
429
+ function getSessionId() {
430
+ var id = sessionStorage.getItem('_lens_sid');
431
+ if (!id) { id = Math.random().toString(36).substring(2, 11); sessionStorage.setItem('_lens_sid', id); }
432
+ return id;
433
+ }
434
+
435
+ // ── Onboarding widget (publisher opt-in via data-onboard="true") ────────
436
+ function maybeShowOnboardWidget(profile) {
437
+ // Show once after 60s on first visit, only if no explicit preference set yet
438
+ if (!ONBOARD) return;
439
+ if (profile.style._explicit) return; // already answered
440
+ if (profile.visits > 1) return; // only first visit
441
+
442
+ setTimeout(function () {
443
+ ensureStyle(profile);
444
+ if (profile.style._explicit) return; // answered while we waited
445
+
446
+ var toast = d.createElement('div');
447
+ toast.id = '__lens_toast__';
448
+ toast.style.cssText = [
449
+ 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%)',
450
+ 'background:#1e293b;color:#e2e8f0;border-radius:10px',
451
+ 'padding:14px 18px;font-family:system-ui,sans-serif;font-size:13px',
452
+ 'box-shadow:0 8px 28px rgba(0,0,0,.35);z-index:999999',
453
+ 'display:flex;align-items:center;gap:12px;max-width:340px'
454
+ ].join(';');
455
+
456
+ toast.innerHTML = [
457
+ '<span style="flex:1;line-height:1.4">Prefer the quick summary or full detail?</span>',
458
+ '<button id="__lens_concise__" style="background:#6366f1;color:#fff;border:none;border-radius:6px;padding:6px 12px;cursor:pointer;font-size:12px;font-weight:600;white-space:nowrap">Quick</button>',
459
+ '<button id="__lens_detail__" style="background:#334155;color:#e2e8f0;border:none;border-radius:6px;padding:6px 12px;cursor:pointer;font-size:12px;font-weight:600;white-space:nowrap">Full detail</button>',
460
+ '<button id="__lens_dismiss__" style="background:none;border:none;color:#64748b;cursor:pointer;font-size:16px;line-height:1;padding:0 2px" title="Dismiss">×</button>'
461
+ ].join('');
462
+
463
+ d.body.appendChild(toast);
464
+
465
+ function answer(val) {
466
+ var p = loadProfile();
467
+ ensureStyle(p);
468
+ p.style.prefersDetail = val;
469
+ p.style._explicit = true;
470
+ saveProfile(p);
471
+ toast.remove();
472
+ }
473
+
474
+ d.getElementById('__lens_concise__').addEventListener('click', function () { answer(0.2); });
475
+ d.getElementById('__lens_detail__').addEventListener('click', function () { answer(0.8); });
476
+ d.getElementById('__lens_dismiss__').addEventListener('click', function () { toast.remove(); });
477
+
478
+ }, 60000);
479
+ }
480
+
481
+ // ── Browser bridge helpers ──────────────────────────────────────────────
482
+ function fromBrowserProfile(p) {
483
+ var cp = p.contentPreferences || {};
484
+ return {
485
+ topics: p.topics || {},
486
+ visits: p.visits || 0,
487
+ lastSeen: Date.now(),
488
+ style: {
489
+ prefersDetail: cp.prefersDetail != null ? cp.prefersDetail : 0.5,
490
+ prefersTechnical: cp.prefersTechnical != null ? cp.prefersTechnical : 0.5,
491
+ prefersNarrative: cp.prefersNarrative != null ? cp.prefersNarrative : 0.5,
492
+ prefersBullets: cp.prefersBullets != null ? cp.prefersBullets : 0.5
493
+ }
494
+ };
495
+ }
496
+
497
+ // ── Rewrite mode ────────────────────────────────────────────────────────
498
+ function applyRewrittenSections(sections, adapted) {
499
+ var resultMap = {};
500
+ for (var i = 0; i < adapted.length; i++) {
501
+ resultMap[adapted[i].id] = adapted[i];
502
+ }
503
+
504
+ sections.forEach(function (s) {
505
+ var result = resultMap[s.id];
506
+ if (!result) return;
507
+
508
+ var el = s.el;
509
+
510
+ // Update score so tracking and emphasis still work correctly
511
+ s.score = result.relevanceScore;
512
+
513
+ // Fade out → swap content → fade in
514
+ el.style.transition = 'opacity 150ms ease';
515
+ el.style.opacity = '0';
516
+
517
+ setTimeout(function () {
518
+ el.innerHTML = result.content;
519
+ el.setAttribute('data-lens-rewritten', 'true');
520
+
521
+ if (DEBUG) {
522
+ var badge = d.createElement('span');
523
+ badge.className = 'lens-rewrite-badge';
524
+ badge.textContent = 'AI ' + Math.round(result.relevanceScore * 100) + '%';
525
+ el.insertBefore(badge, el.firstChild);
526
+ }
527
+
528
+ el.style.opacity = '1';
529
+ }, 160);
530
+ });
531
+ }
532
+
533
+ function runRewrite(profile, sections, expandEvents) {
534
+ // Need at least 3 topics; fall back to both mode if not
535
+ if (Object.keys(profile.topics).length < 3) {
536
+ if (DEBUG) console.log('[Lens] rewrite: fewer than 3 topics — falling back to both mode');
537
+ adapt(sections, profile, expandEvents);
538
+ return;
539
+ }
540
+
541
+ // Require API_URL to be set
542
+ if (!API_URL) {
543
+ if (DEBUG) console.log('[Lens] rewrite: no API_URL configured — falling back to both mode');
544
+ adapt(sections, profile, expandEvents);
545
+ return;
546
+ }
547
+
548
+ injectStyles();
549
+
550
+ var payload = JSON.stringify({
551
+ siteId: SITE_ID,
552
+ path: w.location.pathname,
553
+ sections: sections.map(function (s) { return { id: s.id, text: s.text }; }),
554
+ profile: {
555
+ topics: profile.topics,
556
+ contentPreferences: profile.style
557
+ }
558
+ });
559
+
560
+ fetch(API_URL + '/adapt', {
561
+ method: 'POST',
562
+ headers: Object.assign(
563
+ { 'content-type': 'application/json' },
564
+ API_KEY ? { 'Authorization': 'Bearer ' + API_KEY } : {}
565
+ ),
566
+ body: payload
567
+ })
568
+ .then(function (res) {
569
+ if (res.status === 503) {
570
+ if (DEBUG) console.log('[Lens] rewrite: server has no AI provider — falling back to both mode');
571
+ adapt(sections, profile, expandEvents);
572
+ return null;
573
+ }
574
+ if (!res.ok) throw new Error('HTTP ' + res.status);
575
+ return res.json();
576
+ })
577
+ .then(function (data) {
578
+ if (!data) return; // already fell back
579
+ if (!data.sections || !data.sections.length) {
580
+ if (DEBUG) console.log('[Lens] rewrite: empty response — falling back to both mode');
581
+ adapt(sections, profile, expandEvents);
582
+ return;
583
+ }
584
+ if (DEBUG) console.log('[Lens] rewrite: applying ' + data.sections.length + ' rewritten sections');
585
+ applyRewrittenSections(sections, data.sections);
586
+ })
587
+ .catch(function (err) {
588
+ if (DEBUG) console.warn('[Lens] rewrite error (' + err.message + ') — falling back to both mode');
589
+ adapt(sections, profile, expandEvents);
590
+ });
591
+ }
592
+
593
+ function runInit(profile) {
594
+ ensureStyle(profile);
595
+ var sections = detectSections();
596
+ if (sections.length < 3) return;
597
+
598
+ sections.forEach(function (s) { s.score = scoreSection(s.text, profile); });
599
+
600
+ var expandEvents = []; // shared between adapt() and startTracking()
601
+
602
+ // A/B control group: track signals but skip all visual adaptation
603
+ if (AB_GROUP === 'control') {
604
+ if (DEBUG) console.log('[Lens] A/B group: control — tracking only, no adaptation');
605
+ startTracking(sections, expandEvents);
606
+ maybeShowOnboardWidget(profile);
607
+ return;
608
+ }
609
+
610
+ if (DEBUG && AB_GROUP === 'adapted') console.log('[Lens] A/B group: adapted');
611
+
612
+ if (MODE === 'rewrite') {
613
+ // Wait for DOMContentLoaded + 300ms to let page settle, then attempt rewrite
614
+ function doRewrite() {
615
+ setTimeout(function () {
616
+ runRewrite(profile, sections, expandEvents);
617
+ startTracking(sections, expandEvents);
618
+ maybeShowOnboardWidget(profile);
619
+ }, 300);
620
+ }
621
+ if (d.readyState === 'loading') {
622
+ d.addEventListener('DOMContentLoaded', doRewrite);
623
+ } else {
624
+ doRewrite();
625
+ }
626
+ } else {
627
+ adapt(sections, profile, expandEvents);
628
+ startTracking(sections, expandEvents);
629
+ maybeShowOnboardWidget(profile);
630
+ }
631
+
632
+ if (DEBUG) {
633
+ console.log('[Lens] ' + sections.length + ' sections. Topics: ' +
634
+ Object.keys(profile.topics).length + '. Visits: ' + profile.visits + '. Mode: ' + MODE);
635
+ console.log('[Lens] Style — prefersDetail: ' + profile.style.prefersDetail.toFixed(2) +
636
+ ', prefersTechnical: ' + profile.style.prefersTechnical.toFixed(2));
637
+ console.table(
638
+ sections.slice(0, 10).map(function(s) {
639
+ return { id: s.id, score: Math.round(s.score * 100) + '%', preview: s.preview };
640
+ })
641
+ );
642
+ }
643
+ }
644
+
645
+ // ── Init ────────────────────────────────────────────────────────────────
646
+ function init() {
647
+ // Inside Lens browser — use the cross-site profile instead of per-domain localStorage
648
+ if (w.__lens__ && w.__lens__.isBrowser) {
649
+ w.__lens__.getProfile()
650
+ .then(function (browserProfile) { runInit(fromBrowserProfile(browserProfile)); })
651
+ .catch(function () { runInit(loadProfile()); });
652
+ return;
653
+ }
654
+ runInit(loadProfile());
655
+ }
656
+
657
+ if (d.readyState === 'loading') { d.addEventListener('DOMContentLoaded', init); }
658
+ else { init(); }
659
+
660
+ }(window, document));