-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbackground.js
More file actions
227 lines (197 loc) · 11.2 KB
/
background.js
File metadata and controls
227 lines (197 loc) · 11.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
/* Lens v4 — background.js */
const SHOPPING_HOSTS = ['amazon.in','amazon.com','flipkart.com','myntra.com','meesho.com','nykaa.com','ajio.com','snapdeal.com','tatacliq.com','reliancedigital.in','croma.com','jiomart.com','bigbasket.com'];
const PRODUCT_RE = [/\/dp\//i,/\/p\//i,/\/buy$/i,/\/p\/[a-z0-9]+$/i,/\/product\//i,/\/p---/i,/\/\d{5,}/];
function isShoppingProduct(url) {
try {
const u = new URL(url);
const host = u.hostname.replace('www.','');
if (!SHOPPING_HOSTS.some(h=>host.includes(h))) return false;
return PRODUCT_RE.some(r=>r.test(u.pathname));
} catch(_) { return false; }
}
async function scrapeTab(tabId) {
try {
const res = await chrome.scripting.executeScript({
target:{ tabId },
func: () => {
const q = s => document.querySelector(s);
const nameEl = q('#productTitle') || q('h1.pdp-name') || q('[class*="pdp-title"]') || q('[class*="product-title"]') || q('h1');
const priceEl = q('.a-price .a-offscreen') || q('#priceblock_ourprice') || q('._30jeq3') || q('[class*="selling-price"]') || q('[class*="pdp-price"]');
const ratEl = q('#acrPopover') || q('[data-hook="average-star-rating"]') || q('._3LWZlK') || q('[class*="averageRating"]');
const cntEl = q('#acrCustomerReviewText') || q('[data-hook="total-review-count"]') || q('[class*="reviewCount"]');
const specEl = q('#productDetails_feature_div') || q('#tech-specs-section') || q('._2418kt') || q('[class*="specification"]');
const descEl = q('#feature-bullets') || q('#productDescription') || q('[class*="product-description"]');
const RSELS = ['[data-hook="review"]','._27M-vq','[class*="ReviewCard"]','[class*="review-card"]'];
const reviews = []; const seen = new WeakSet();
for (const sel of RSELS) {
try { document.querySelectorAll(sel).forEach(el => {
if (seen.has(el) || reviews.length >= 8) return;
seen.add(el);
const b = el.querySelector('[data-hook="review-body"],[class*="reviewBody"],p');
const s = el.querySelector('[data-hook="review-star-rating"],[class*="starRating"]');
const t = b ? b.textContent.trim().slice(0,200) : el.textContent.trim().slice(0,150);
if (t.length > 15) reviews.push({ text:t, stars:s?s.textContent.trim().slice(0,5):'' });
}); } catch(_) {}
}
const name = nameEl ? nameEl.textContent.trim().replace(/\s+/g,' ').slice(0,120) : document.title.slice(0,80);
return {
url: location.href,
site: location.hostname.replace('www.',''),
name,
price: priceEl ? priceEl.textContent.trim().slice(0,30) : '',
rating: ratEl ? ratEl.textContent.trim().slice(0,20) : '',
reviewCount: cntEl ? cntEl.textContent.trim().slice(0,30) : '',
specs: specEl ? specEl.textContent.trim().replace(/\s+/g,' ').slice(0,400) : '',
description: descEl ? descEl.textContent.trim().replace(/\s+/g,' ').slice(0,300) : '',
reviews,
};
}
});
return res?.[0]?.result || null;
} catch(e) { return null; }
}
async function getTabName(tabId) {
try {
const res = await chrome.scripting.executeScript({
target:{ tabId },
func: () => {
const el = document.querySelector('#productTitle,h1.pdp-name,[class*="pdp-title"],[class*="product-title"],h1');
return el ? el.textContent.trim().slice(0,120) : document.title.slice(0,80);
}
});
return res?.[0]?.result || '';
} catch(_) { return ''; }
}
function categoryWords(name) {
const stop = new Set([
'the','a','an','and','or','for','with','in','on','of','by','is','to','from',
'inch','inches','cm','mm','kg','gb','tb','mb','watt','litre','liter','ltr','year',
'warranty','pack','set','new','best','buy','price','online','india','free',
'shipping','delivery','offer','sale','deal','combo','plus','pro','max','mini',
'series','edition','model','version','type','star','rating','review',
]);
// Extract words AND keep compound product terms (e.g. "refrigerator","smartphone","laptop")
const words = name.toLowerCase()
.replace(/[^a-z0-9\s]/g,' ')
.split(/\s+/)
.filter(w => w.length > 2 && !stop.has(w) && !/^\d+$/.test(w));
// Prioritise longer descriptive words (product category terms are usually longer)
return words.sort((a,b) => b.length - a.length).slice(0, 8);
}
function similarity(a,b) {
if (!a.length || !b.length) return 0;
const sa = new Set(a);
return b.filter(w=>sa.has(w)).length / Math.max(a.length,b.length);
}
async function compareWithAI(products, key) {
// Detect provider from key
function detect(k) {
if (!k) return null;
if (k.startsWith('gsk_')) return 'groq';
if (k.startsWith('sk-ant-')) return 'anthropic';
if (k.startsWith('AIza')) return 'gemini';
if (k.startsWith('sk-')) return 'openai';
if (/^[a-f0-9]{64}$/i.test(k)) return 'together';
if (/^[A-Za-z0-9]{32}$/.test(k)) return 'mistral';
return null;
}
const pk = detect(key);
const endpoints = { groq:'https://api.groq.com/openai/v1/chat/completions', openai:'https://api.openai.com/v1/chat/completions', mistral:'https://api.mistral.ai/v1/chat/completions', together:'https://api.together.xyz/v1/chat/completions' };
const models = { groq:'llama-3.3-70b-versatile', openai:'gpt-4o-mini', mistral:'mistral-small-latest', together:'meta-llama/Llama-3-70b-chat-hf' };
const blocks = products.map((p,i)=>
'PRODUCT '+(i+1)+' — "'+p.name.split(/[,|(]/)[0].trim().slice(0,80)+'"'+
'\nSite: '+p.site+' | Price: '+(p.price||'?')+' | Rating: '+(p.rating||'?')+' ('+p.reviewCount+')'+
'\nHighlights: '+p.specs.slice(0,300)+
'\nReviews: '+p.reviews.map(r=>'['+r.stars+'★] '+r.text).join(' | ').slice(0,350)
).join('\n\n---\n\n');
const prompt = 'Compare these '+products.length+' products for an Indian shopper and recommend the best one.\n\n'+blocks+'\n\nReturn ONLY valid JSON:\n{"winner":1,"winnerName":"short name","winnerReason":"one sentence","verdict":"CLEAR_WINNER","products":[{"index":1,"name":"short name","rank":1,"price":"price","rating":"rating","site":"site","pros":["specific pro"],"cons":["specific con"],"bestFor":"short phrase"}],"buyAdvice":"2 sentences"}';
let res;
if (pk === 'anthropic') {
res = await fetch('https://api.anthropic.com/v1/messages', {
method:'POST',
headers:{'Content-Type':'application/json','x-api-key':key,'anthropic-version':'2023-06-01'},
body:JSON.stringify({ model:'claude-haiku-4-5-20251001', max_tokens:1400, system:'You are a product comparison expert. Return only valid JSON.', messages:[{role:'user',content:prompt}] })
});
} else if (pk === 'gemini') {
const ep = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key='+key;
res = await fetch(ep, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ contents:[{parts:[{text:'You are a product comparison expert. Return only valid JSON.\n\n'+prompt}]}], generationConfig:{temperature:0.1,maxOutputTokens:1400} }) });
} else if (pk && endpoints[pk]) {
const body = { model:models[pk], temperature:0.1, max_tokens:1400, messages:[{role:'system',content:'You are a product comparison expert. Return only valid JSON.'},{role:'user',content:prompt}] };
if (pk !== 'together') body.response_format = { type:'json_object' };
res = await fetch(endpoints[pk], { method:'POST', headers:{'Content-Type':'application/json','Authorization':'Bearer '+key}, body:JSON.stringify(body) });
} else {
throw new Error('UNKNOWN_PROVIDER');
}
if (res.status === 401) throw new Error('INVALID_KEY');
if (res.status === 429) throw new Error('RATE_LIMIT');
if (!res.ok) throw new Error('API_'+res.status);
const data = await res.json();
let text = '';
if (pk === 'anthropic') text = data.content?.[0]?.text || '';
else if (pk === 'gemini') text = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
else text = data.choices?.[0]?.message?.content || '';
return JSON.parse(text.replace(/```json|```/g,'').trim());
}
chrome.runtime.onMessage.addListener((msg, sender) => {
if (msg.type !== 'COMPARE_TABS') return;
const senderTabId = sender.tab?.id;
if (!senderTabId) return;
(async () => {
try {
const allTabs = await chrome.tabs.query({});
const productTabs = allTabs.filter(t => t.url && isShoppingProduct(t.url) && t.status==='complete');
const seen = new Set();
const uniqueTabs = productTabs.filter(t=>{ if(seen.has(t.id)) return false; seen.add(t.id); return true; });
if (uniqueTabs.length < 2) {
chrome.tabs.sendMessage(senderTabId, { type:'COMPARE_ERROR', error:'NEED_MORE_TABS', count:uniqueTabs.length });
return;
}
// ── Smart filter: only compare similar products ──
const senderName = await getTabName(senderTabId);
const senderWords = categoryWords(senderName);
console.log('[Lens] Sender product:', senderName, '| words:', senderWords);
// Score ALL product tabs for similarity — no slice cap yet
const scored = await Promise.all(uniqueTabs.map(async t => {
if (t.id === senderTabId) return { tab:t, score:1.0, name:senderName };
const name = await getTabName(t.id);
const words = categoryWords(name);
const score = similarity(senderWords, words);
console.log('[Lens] Tab:', name, '| words:', words, '| score:', score.toFixed(2));
return { tab:t, score, name };
}));
// Use threshold 0.25 — needs at least 1-2 strong category words in common
const THRESHOLD = 0.25;
const similarTabs = scored
.filter(s => s.score >= THRESHOLD || s.tab.id === senderTabId)
.sort((a,b) => b.score - a.score) // highest similarity first
.slice(0, 5) // cap at 5 after filtering
.map(s => s.tab);
const removed = uniqueTabs.length - similarTabs.length;
console.log('[Lens] Similar tabs:', similarTabs.length, '| Removed:', removed);
if (removed > 0) {
chrome.tabs.sendMessage(senderTabId, { type:'COMPARE_PROGRESS', step:'filtered', kept:similarTabs.length, removed });
}
if (similarTabs.length < 2) {
chrome.tabs.sendMessage(senderTabId, { type:'COMPARE_ERROR', error:'NEED_MORE_TABS', count:similarTabs.length });
return;
}
chrome.tabs.sendMessage(senderTabId, { type:'COMPARE_PROGRESS', step:'scraping', count:similarTabs.length });
const scraped = await Promise.all(similarTabs.map(t => scrapeTab(t.id)));
const products = scraped.filter(p=>p&&p.name);
if (products.length < 2) {
chrome.tabs.sendMessage(senderTabId, { type:'COMPARE_ERROR', error:'SCRAPE_FAILED' });
return;
}
chrome.tabs.sendMessage(senderTabId, { type:'COMPARE_PROGRESS', step:'comparing', count:products.length });
const result = await compareWithAI(products, msg.groqKey);
chrome.tabs.sendMessage(senderTabId, {
type:'COMPARE_RESULT',
result,
products: products.map(p=>({ name:p.name, price:p.price, rating:p.rating, site:p.site, url:p.url }))
});
} catch(err) {
chrome.tabs.sendMessage(senderTabId, { type:'COMPARE_ERROR', error:err.message });
}
})();
// No return true
});