openvibers/openvibe.extensions Commits
OpenVibe Companion 0.5.0: Save its rating to OpenVibe Reviews (on Glassdoor, Indeed, Yelp, the BBB, Trustpilot, Google Maps or Tripadvisor it reads the rating the page states, from its structured data or the stars' own label, and opens openvibe.reviews/save to check and save it; lib/rating.js only reads, and only on the press) (#6)
Changes
@@@ -42,6 +42,11 @@ One button in the browser for the OpenVibe sites that work with the page you are- **Compare delivery apps.** On DoorDash, Uber Eats, Grubhub, Seamless, Caviar or Instacart it opens[openvibe.food/delivery](https://openvibe.food/delivery) with the restaurant or store's name (read from the pagetitle, never the address or the cart), and every app's own search for it opens from there.++- **Save its rating to OpenVibe Reviews.** On Glassdoor, Indeed, Yelp, the BBB, Trustpilot, Google Maps or++ Tripadvisor, it reads the rating the page states (the structured data the site publishes for search engines, else++ the stars' own label, like "4.5 star rating") and opens [openvibe.reviews/save](https://openvibe.reviews/save) with++ the page's address, its name and the numbers. You check them against the page there and save; the rating then shows++ beside that site's link on the OpenVibe.Reviews page, with the date. On a review site this is the main button.- **Try these codes on this page.** At a shop's cart or checkout, one button types the codes OpenVibe.Coupons knowsinto the page's own coupon box, presses that box's Apply button, and reads the order total before and after. Itstops at the first code that lowers the total and leaves it applied. Codes reported working go first, codes@@@ -64,11 +69,11 @@ the Companion never rewrites links or adds referral tags.| | ||---|---|--| **Permissions** | `activeTab`, `scripting` and `storage`. The host permission is `https://openvibe.coupons/*` only. `scripting` is used for one thing: "Try these codes on this page" injects the packaged `lib/autoapply.js` into the active tab (the access `activeTab` gives after you press the button), in its top frame and the extension's isolated world. |++| **Permissions** | `activeTab`, `scripting` and `storage`. The host permission is `https://openvibe.coupons/*` only. `scripting` is used for two buttons: "Try these codes on this page" injects the packaged `lib/autoapply.js`, and "Save its rating to OpenVibe Reviews" injects `lib/rating.js`, into the active tab (the access `activeTab` gives after you press the button), in its top frame and the extension's isolated world. || **Not requested** | `<all_urls>`, `tabs`, `cookies`, `webRequest`, `history`, optional permissions, content scripts, a background worker, web-accessible resources, `externally_connectable`. |--| **Read from the page** | The active tab's **URL and title**, when you open the popup (that's what `activeTab` grants). The page's content only when you press "Try these codes on this page": `lib/autoapply.js` finds the coupon box, its Apply button and the order total, types the codes in, and returns to the popup which codes changed the total. It makes no request, reads no cookies or storage, and sends nothing anywhere; `test/manifest.test.js` scans it for that. Browser pages, files, IP addresses and local names are "not a site", and nothing is sent or offered for them. |++| **Read from the page** | The active tab's **URL and title**, when you open the popup (that's what `activeTab` grants). The page's content only when you press "Try these codes on this page": `lib/autoapply.js` finds the coupon box, its Apply button and the order total, types the codes in, and returns to the popup which codes changed the total. It makes no request, reads no cookies or storage, and sends nothing anywhere; `test/manifest.test.js` scans it for that. On a review site, "Save its rating to OpenVibe Reviews" runs `lib/rating.js`, which only reads: the page's name, its rating, the scale, how many ratings, and the city from its structured data. Browser pages, files, IP addresses and local names are "not a site", and nothing is sent or offered for them. || **Sent by the extension** | To `https://openvibe.coupons` only, and only the **hostname** (`lib/host.js` drops the path, query, fragment, port and credentials first): `GET /api/v1/merchants/resolve?host=`, the merchant id (`GET /api/v1/merchants/:id/coupons`), and, when you report, the code id plus `worked` or `failed` with an optional reason. Every request goes with `credentials: 'omit'` (no cookies), `referrerPolicy: 'no-referrer'` and `redirect: 'error'`. |--| **Hand-offs (only when you press the button)** | "Keep on my Rent board", "Check the rent" and "Watch this page" open **a new tab you see** on `https://openvibe.rent/board`, `https://openvibe.rent/fair` or `https://openvibe.watch/watches/new`, with the page's address and title in that tab's address. `lib/handoff.js` removes the credentials, the fragment and tracking parameters (`utm_*`, `fbclid`, `gclid`, …) first. The extension makes no request for it, nothing opens on its own, and nothing is saved until you save it on that site. `platform.tabs.open()` refuses any other origin, and the permission audit allows only `https://openvibe.<tld>` hand-off origins. |++| **Hand-offs (only when you press the button)** | "Keep on my Rent board", "Check the rent", "Watch this page", "Compare delivery apps" and "Save its rating to OpenVibe Reviews" open **a new tab you see** on `https://openvibe.rent/board`, `https://openvibe.rent/fair`, `https://openvibe.watch/watches/new`, `https://openvibe.food/delivery` or `https://openvibe.reviews/save`, with the page's address and title (and, for Reviews, the rating read from it) in that tab's address. `lib/handoff.js` removes the credentials, the fragment and tracking parameters (`utm_*`, `fbclid`, `gclid`, …) first. The extension makes no request for it, nothing opens on its own, and nothing is saved until you save it on that site. `platform.tabs.open()` refuses any other origin, and the permission audit allows only `https://openvibe.<tld>` hand-off origins. || **Stored** | Only the optional install token, in `storage.local`. It is never synced to other devices. || **CSP** | `default-src 'none'; script-src 'self'; connect-src https://openvibe.coupons; object-src 'none'`. There is no inline script, no remote code, no `eval`, and no `innerHTML`: the popup builds DOM nodes and sets text. |@@@ -100,10 +105,11 @@ browser/manifest.json MV3; Chromium (minimum_chrome_version 111) and Firefox (gecko id, strict_min_version 128)popup.html / popup.js DOM wiring onlyoptions.html / .js connect (save a checked token), disconnect, grant the API host permission-- lib/config.js API_ORIGIN (the one origin it sends requests to); HANDOFF_ORIGINS (Rent, Watch)++ lib/config.js API_ORIGIN (the one origin it sends requests to); HANDOFF_ORIGINS (Rent, Watch, Food, Reviews)lib/host.js URL → hostname (everything else dropped)-- lib/handoff.js the Rent board, Watch and Food delivery addresses, built from a cleaned URL; which sites are rentals++ lib/handoff.js the Rent board, Watch, Food delivery and Reviews addresses, built from a cleaned URL; which sites are rentals and review siteslib/autoapply.js "Try these codes on this page": injected into the active tab only when you press it++ lib/rating.js "Save its rating to OpenVibe Reviews": reads a review site's rating, injected only when you press itlib/api.js the only code that makes requestslib/controller.js popup logic without the DOM (tested in Node)lib/platform.js chrome.* / browser.*: tabs.query, tabs.create (hand-off pages only), storage.local, permissions@@@ -8,7 +8,7 @@"surfaces": {"browser": {"name": "OpenVibe Companion",-- "version": "0.3.0",++ "version": "0.5.0","manifest": 3,"targets": ["chromium >= 111",@@@ -17,6 +17,7 @@"api": "https://openvibe.coupons (OpenVibe.Coupons public API)","permissions": ["activeTab",++ "scripting","storage"],"hostPermissions": [@@@ -28,7 +29,10 @@"loadedInFirefox": "not yet verified","handoffs": ["https://openvibe.rent/board (keep a listing)",-- "https://openvibe.watch/watches/new (watch a page)"++ "https://openvibe.rent/fair (check the rent)",++ "https://openvibe.watch/watches/new (watch a page)",++ "https://openvibe.food/delivery (compare delivery apps)",++ "https://openvibe.reviews/save (a review site's rating, checked and saved on Reviews)"]}},@@@ -6,7 +6,8 @@* a development build (--api http://localhost:4850) and test/manifest.test.js checks they agree.** HANDOFF_ORIGINS are the sites the Companion may open a tab on when you press one of its buttons-- * (lib/handoff.js): the Rent board, a new Watch, and Food's delivery comparison. It sends them nothing++ * (lib/handoff.js): the Rent board, a new Watch, Food's delivery comparison, and Reviews' page for saving the rating++ * a review site shows. It sends them nothing* itself; the address of the tab it opens carries the page you were on, and you see it.** MAX_TRIES is how many codes "Try these codes on this page" types into a shop's coupon box at most@@@ -23,7 +24,8 @@RENT_ORIGIN: 'https://openvibe.rent',WATCH_ORIGIN: 'https://openvibe.watch',FOOD_ORIGIN: 'https://openvibe.food',-- HANDOFF_ORIGINS: Object.freeze(['https://openvibe.rent', 'https://openvibe.watch', 'https://openvibe.food']),++ REVIEWS_ORIGIN: 'https://openvibe.reviews',++ HANDOFF_ORIGINS: Object.freeze(['https://openvibe.rent', 'https://openvibe.watch', 'https://openvibe.food', 'https://openvibe.reviews']),MAX_TRIES: 10,CONNECT_PATH: '/connect-extension',TOKEN_KEY: 'installToken',@@@ -8,6 +8,7 @@* await c.checkRent() // is this rent fair? on openvibe.rent/fair (a new tab)* await c.watchPage() // open a new Watch for this page (a new tab)* await c.compareDelivery() // the restaurant on every delivery app, on openvibe.food (a new tab)++ * await c.saveRating() // a review site's rating (lib/rating.js reads it) → openvibe.reviews/save (a new tab)* await c.tryCodes(coupons) // type the codes into this page's coupon box (lib/autoapply.js) → a view of it* await c.report(couponId, 'worked') // → { ok, message }*@@@ -81,6 +82,7 @@handoff: ok,rental: ok && deps.isRental ? deps.isRental(tab.url) : false,delivery: ok && Boolean(handoff.deliveryUrl && handoff.deliveryUrl(tab.url, tab.title)),++ review: ok && Boolean(handoff.reviewUrl && handoff.reviewUrl(tab.url, tab.title, null)),};},@@@ -89,6 +91,18 @@watchPage() { return handOff(function (u, t) { return handoff.watchUrl(u, t); }); },compareDelivery() { return handOff(function (u, t) { return handoff.deliveryUrl(u, t); }); },++ /**++ * Read the rating this review site's page states (lib/rating.js, injected on this press) and open++ * openvibe.reviews/save with it for the person to check. When the page cannot be read, Reviews' form opens++ * with the page's address and name, for them to type the numbers in.++ */++ async saveRating() {++ let rating = null;++ try { rating = await platform.scripting.readRating(tab.id); } catch (e) { rating = null; }++ const r = await handOff(function (u, t) { return handoff.reviewUrl(u, t, rating); });++ return r.ok ? Object.assign(r, { read: Boolean(rating && typeof rating.value === 'number') }) : r;++ },++/*** Try the codes in this page's own coupon box. → { ok, message, results: [{ id, code, outcome, saved }] }.* Codes reported working come first; at most config.MAX_TRIES.@@@ -9,6 +9,9 @@* deliveryUrl(url, title) https://openvibe.food/delivery?q=… the same restaurant on every delivery app* (on a DoorDash, Uber Eats, Grubhub, Seamless, Caviar or Instacart page: the restaurant or* store name read from the page title, never the address or the cart)++ * reviewUrl(url, title, rating) https://openvibe.reviews/save?site=…&url=…&name=…&rating=… the rating a review++ * site's page shows (Glassdoor, Indeed, Yelp, the BBB, Trustpilot, Google Maps, Tripadvisor;++ * read by lib/rating.js when the person presses the button), for them to check and save there** This is the only code that uses more of the page than its hostname, and what it builds is a page* address opened in a new tab you see, never a request the extension makes. cleanUrl() drops the@@@ -52,6 +55,50 @@function isDelivery(url) { return Boolean(deliveryApp(url)); }++ /** Review sites whose pages state a rating OpenVibe.Reviews shows beside their link (its SEEN_SITES). */++ const REVIEW_SITES = [++ { site: 'glassdoor', host: /(^|\.)glassdoor\.[a-z.]{2,12}$/, kind: 'employer' },++ { site: 'indeed', host: /(^|\.)indeed\.[a-z.]{2,12}$/, kind: 'employer', path: /^\/cmp\// },++ { site: 'yelp', host: /(^|\.)yelp\.[a-z.]{2,12}$/, path: /^\/biz\// },++ { site: 'bbb', host: /(^|\.)bbb\.org$/, kind: 'business' },++ { site: 'trustpilot', host: /(^|\.)trustpilot\.com$/, path: /^\/review\// },++ { site: 'google', host: /(^|\.)google\.[a-z.]{2,12}$/, path: /^\/maps\b/ },++ { site: 'tripadvisor', host: /(^|\.)tripadvisor\.[a-z.]{2,12}$/ },++ ];++ const TYPE_KIND = [[/restaurant|food|cafe|bakery|bar(?:Or)?|pub|winery|brewery/i, 'restaurant'], [/hotel|lodging|attraction|landmark|park|museum/i, 'place'],++ [/localbusiness|store|shop|dentist|medical|automotive|homeandconstruction|legal|financial/i, 'business'], [/softwareapplication|mobileapplication|webapplication/i, 'app'],++ [/organization|corporation/i, 'organization']];++++ /** Which review site a page is on, or null (Google only on its Maps pages). */++ function reviewSite(url) {++ let u;++ try { u = new URL(String(url)); } catch (e) { return null; }++ if (u.protocol !== 'https:') return null;++ const h = u.hostname.toLowerCase();++ const r = REVIEW_SITES.find(function (x) { return x.host.test(h) && (!x.path || x.path.test(u.pathname) || (x.site === 'google' && /^maps\./.test(h))); });++ return r || null;++ }++++ function reviewUrl(REVIEWS, url, title, rating) {++ const r = reviewSite(url);++ const page = r && cleanUrl(url);++ if (!page) return null;++ const got = rating || {};++ const q = new URLSearchParams({ site: r.site, url: page });++ const name = cleanTitle(got.name, 200) || cleanTitle(String(title || '').split(/\s+[|\-\u2013\u2014]\s+/)[0], 200);++ if (name) q.set('name', name);++ const kind = r.kind || (TYPE_KIND.find(function (k) { return k[0].test(String(got.type || '')); }) || [])[1];++ if (kind) q.set('kind', kind);++ if (got.place) q.set('place', cleanTitle(got.place, 120));++ if (typeof got.value === 'number' && Number.isFinite(got.value)) {++ q.set('rating', String(got.value));++ q.set('best', String(typeof got.best === 'number' && got.best > 0 ? got.best : 5));++ if (Number.isInteger(got.count)) q.set('count', String(got.count));++ }++ q.set('via', 'companion');++ return REVIEWS + '/save?' + q.toString();++ }++/** The restaurant or store a delivery app's page is about, from its title; '' when the title names none. */function deliveryQuery(url, title) {const app = deliveryApp(url);@@@ -95,6 +142,7 @@const RENT = config.RENT_ORIGIN;const WATCH = config.WATCH_ORIGIN;const FOOD = config.FOOD_ORIGIN;++ const REVIEWS = config.REVIEWS_ORIGIN;const origins = config.HANDOFF_ORIGINS;return {boardUrl: function (url, title) { return boardUrl(RENT, url, title); },@@@ -105,6 +153,7 @@const q = deliveryQuery(url, title);return FOOD + '/delivery' + (q ? '?' + new URLSearchParams({ q: q }).toString() : '');},++ reviewUrl: function (url, title, rating) { return REVIEWS ? reviewUrl(REVIEWS, url, title, rating) : null; },isHandoff: function (url) { return isHandoff(origins, url); },};}@@@ -141,5 +190,5 @@return typeof url === 'string' && origins.some(function (o) { return url.indexOf(o + '/') === 0; });}-- return { createHandoff, RENTAL_HOSTS, cleanUrl, isRental, isHandoff, isDelivery, deliveryQuery };++ return { createHandoff, RENTAL_HOSTS, REVIEW_SITES, cleanUrl, isRental, isHandoff, isDelivery, deliveryQuery, reviewSite };});@@@ -13,6 +13,8 @@* scripting.tryCodes(tabId, codes) when the person presses "Try these codes on this page": inject* lib/autoapply.js into that tab (activeTab grants it for this one tab, after* that click) and run it with the codes; returns what it saw++ * scripting.readRating(tabId) when the person presses "Save its rating to OpenVibe Reviews" on a review++ * site: inject lib/rating.js the same way and return the rating the page states** Nothing else: no cookies, no history, and the page is touched only by that one button.*/@@@ -54,6 +56,12 @@}]);return out && out[0] ? out[0].result : null;},++ async readRating(tabId) {++ if (!ext.scripting || !Number.isInteger(tabId)) throw new TypeError('this browser cannot read the page');++ await call(ext.scripting.executeScript, ext.scripting, [{ target: { tabId: tabId }, files: ['lib/rating.js'] }]);++ const out = await call(ext.scripting.executeScript, ext.scripting, [{ target: { tabId: tabId }, func: function () { return self.OVRating.read(document); } }]);++ return out && out[0] ? out[0].result : null;++ },},storage: {async getToken() {nnew file mode 100644@@@ -0,0 +1,163 @@++/*++ * Read the rating a review site's page states: the OpenVibe Companion's "Save its rating to OpenVibe Reviews".++ *++ * Injected into the active tab only when the person presses that button in the popup (activeTab + scripting, as++ * lib/autoapply.js is), on Glassdoor, Indeed, Yelp, the BBB, Trustpilot, Google Maps or Tripadvisor. It reads the++ * page's DOM in the extension's isolated world, makes no request, reads no cookies or storage, and returns only:++ *++ * read(document) → { name, value, best, count, place, type, how } | null++ * name what the page is about (its structured data's name, else its heading)++ * value the rating, on the site's own scale; best: the top of that scale (5 when the page does not say)++ * count how many ratings it is from, or null++ * place "City, Region" from the structured data's address, or ''++ * type the structured data's @type (Restaurant, Organization…), or ''++ * how 'structured' (JSON-LD or microdata the site publishes for search engines) or 'page' (the stars' own++ * accessible label, like "4.5 star rating")++ *++ * The popup opens openvibe.reviews/save with these values; the person checks them against the page there before++ * anything is saved.++ */++(function (root, factory) {++ const api = factory();++ if (typeof module === 'object' && module.exports) module.exports = api;++ else root.OVRating = api;++})(typeof self !== 'undefined' ? self : this, function () {++ 'use strict';++++ function num(v) {++ if (v == null) return null;++ const s = String(typeof v === 'object' && v['@value'] != null ? v['@value'] : v).trim().replace(/\s/g, '');++ // "1,234" and "1.234" (thousands) as counts; "4,5" (decimal comma) as a rating.++ const m = /^(\d{1,3}(?:[,.]\d{3})+|\d+(?:[.,]\d+)?)$/.exec(s);++ if (!m) return null;++ const t = /^\d{1,3}(?:[,.]\d{3})+$/.test(m[1]) ? m[1].replace(/[,.]/g, '') : m[1].replace(',', '.');++ const n = Number(t);++ return Number.isFinite(n) ? n : null;++ }++++ function text(v, max) {++ return String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max || 200);++ }++++ function typeOf(o) {++ const t = o && o['@type'];++ return text(Array.isArray(t) ? t[0] : t, 60);++ }++++ function placeOf(o) {++ const a = o && (Array.isArray(o.address) ? o.address[0] : o.address);++ if (!a) return '';++ if (typeof a === 'string') return text(a, 120);++ const city = text(a.addressLocality, 60);++ const region = text(a.addressRegion, 40) || text(typeof a.addressCountry === 'object' && a.addressCountry ? a.addressCountry.name : a.addressCountry, 40);++ return [city, region].filter(Boolean).join(', ');++ }++++ function ratingOf(owner, r) {++ const value = num(r.ratingValue);++ const best = num(r.bestRating) || 5;++ if (value == null || best <= 0 || value > best) return null;++ const count = num(r.ratingCount) != null ? num(r.ratingCount) : num(r.reviewCount);++ return { name: text(owner && owner.name, 200), value, best, count: count != null && Number.isInteger(count) ? count : null, place: placeOf(owner), type: typeOf(owner), how: 'structured' };++ }++++ /** The first aggregateRating in a page's JSON-LD blocks (their text), with what it rates; null when none. */++ function fromJsonLd(blocks) {++ const seen = [];++ const walk = function (o, depth) {++ if (!o || typeof o !== 'object' || depth > 6 || seen.length > 2000) return null;++ seen.push(o);++ if (Array.isArray(o)) {++ for (const x of o) { const r = walk(x, depth + 1); if (r) return r; }++ return null;++ }++ if (o.aggregateRating && typeof o.aggregateRating === 'object') {++ const r = ratingOf(o.itemReviewed || o, Array.isArray(o.aggregateRating) ? o.aggregateRating[0] : o.aggregateRating);++ if (r) return r;++ }++ // An AggregateRating (or EmployerAggregateRating) on its own, naming what it rates.++ if (/AggregateRating$/.test(typeOf(o)) && o.ratingValue != null) {++ const r = ratingOf(o.itemReviewed || {}, o);++ if (r) return r;++ }++ for (const k of ['@graph', 'mainEntity', 'itemReviewed', 'about']) {++ if (o[k]) { const r = walk(o[k], depth + 1); if (r) return r; }++ }++ return null;++ };++ for (const b of blocks || []) {++ let data;++ try { data = JSON.parse(String(b || '')); } catch (e) { continue; }++ const r = walk(data, 0);++ if (r) return r;++ }++ return null;++ }++++ // The stars' accessible labels: "4.5 star rating" (Yelp), "4.5 stars" (Google Maps), "4.5 of 5 bubbles"++ // (Tripadvisor), "Rated 4.2 out of 5" (Trustpilot, Indeed), "3.9 out of 5 stars" (Glassdoor).++ const LABEL_RES = [++ /^\s*rated\s+(\d+(?:[.,]\d+)?)\s+(?:out\s+)?of\s+(\d+)/i,++ /^\s*(\d+(?:[.,]\d+)?)\s*(?:out\s+of|of|\/)\s*(\d+)(?:\s*(?:stars?|bubbles?|points?))?\b/i,++ /^\s*(\d+(?:[.,]\d+)?)\s*(?:-|\s)?\s*stars?(?:\s+rating)?\s*$/i,++ /^\s*(\d+(?:[.,]\d+)?)\s+bubbles?\b/i,++ ];++ const COUNT_RE = /^\s*\(?\s*(\d[\d,.\s]*)\s*\)?\s*(?:reviews?|ratings?|bewertungen|avis)?\s*$/i;++++ /** A rating from the stars' labels, in page order; the count from the first "1,234 reviews" label after it. */++ function fromLabels(labels) {++ const list = (labels || []).map(function (l) { return text(l, 120); }).filter(Boolean);++ for (let i = 0; i < list.length; i++) {++ for (const re of LABEL_RES) {++ const m = re.exec(list[i]);++ if (!m) continue;++ const value = num(m[1]);++ const best = m[2] ? num(m[2]) : 5;++ if (value == null || !best || value > best) continue;++ let count = null;++ for (let j = i + 1; j < Math.min(list.length, i + 6) && count == null; j++) {++ if (/\b(reviews?|ratings?)\b/i.test(list[j]) || /^\(\s*\d/.test(list[j])) {++ const c = COUNT_RE.exec(list[j]);++ if (c) count = num(c[1].replace(/\s/g, ''));++ }++ }++ return { value, best, count: count != null && Number.isInteger(count) ? count : null, how: 'page' };++ }++ }++ return null;++ }++++ /** The page's own name for itself: its heading, else its title without the site's suffix. */++ function nameFrom(h1, title) {++ const h = text(h1, 200);++ if (h) return h;++ return text(String(title || '').split(/\s+[|\-–—]\s+/)[0].replace(/\s+(Reviews?|Employee Reviews|Company Reviews)$/i, ''), 200);++ }++++ /** Microdata (itemprop="aggregateRating") as JSON-LD would say it. */++ function fromMicrodata(doc) {++ const box = doc.querySelector('[itemprop="aggregateRating"]');++ if (!box) return null;++ const prop = function (el, name) {++ const n = el.querySelector('[itemprop="' + name + '"]');++ return n ? (n.getAttribute('content') || n.textContent) : null;++ };++ const scope = box.parentElement && box.parentElement.closest ? box.parentElement.closest('[itemscope]') : null;++ const r = ratingOf({ name: scope ? prop(scope, 'name') : '' }, { ratingValue: prop(box, 'ratingValue'), bestRating: prop(box, 'bestRating'), ratingCount: prop(box, 'ratingCount'), reviewCount: prop(box, 'reviewCount') });++ return r;++ }++++ function read(doc) {++ doc = doc || document;++ const blocks = Array.prototype.map.call(doc.querySelectorAll('script[type="application/ld+json"]'), function (s) { return s.textContent; }).slice(0, 40);++ const h1 = doc.querySelector('h1');++ const name = nameFrom(h1 && h1.textContent, doc.title);++ const found = fromJsonLd(blocks) || fromMicrodata(doc);++ if (found) return Object.assign(found, { name: found.name || name });++ const labels = Array.prototype.map.call(doc.querySelectorAll('[aria-label], [title]'), function (el) { return el.getAttribute('aria-label') || el.getAttribute('title'); }).slice(0, 3000);++ const page = fromLabels(labels);++ return page ? Object.assign(page, { name: name, place: '', type: '' }) : (name ? { name: name, value: null, best: 5, count: null, place: '', type: '', how: 'none' } : null);++ }++++ return { read, fromJsonLd, fromLabels, nameFrom, num };++});@@@ -2,8 +2,8 @@"manifest_version": 3,"name": "OpenVibe Companion","short_name": "OpenVibe",-- "version": "0.4.0",-- "description": "Coupon codes for the shop you are on, tried in its coupon box when you ask; the same restaurant on every delivery app; and one click to keep a rental listing on your OpenVibe.Rent board or watch a page. Reads the tab only when you open it.",++ "version": "0.5.0",++ "description": "Coupon codes for the shop you are on, tried in its coupon box when you ask; the same restaurant on every delivery app; a review site's rating saved to OpenVibe.Reviews; and one click to keep a rental listing on your OpenVibe.Rent board or watch a page. Reads the tab only when you open it.","homepage_url": "https://github.com/OpenVibers/OpenVibe.Extensions","author": "OpenVibers","icons": {@@@ -17,8 +17,9 @@<button type="button" id="fair" class="btn" hidden>Check the rent</button><button type="button" id="watch" class="btn">Watch this page</button><button type="button" id="delivery" class="btn" hidden>Compare delivery apps</button>++ <button type="button" id="rating" class="btn" hidden>Save its rating to OpenVibe Reviews</button></div>-- <p id="actions-note" class="note muted">Opens openvibe.rent, openvibe.watch or openvibe.food in a new tab with this page’s address.</p>++ <p id="actions-note" class="note muted">Opens openvibe.rent, openvibe.watch, openvibe.food or openvibe.reviews in a new tab with this page’s address.</p></section><main id="content" aria-live="polite"><p class="muted">Looking up this shop…</p></main><footer>@@@ -84,7 +84,7 @@note);}-- /** The two hand-off buttons: the board leads on a rental listing, Watch everywhere else. */++ /** The hand-off buttons: the rating leads on a review site, the comparison on a delivery app, the board on a rental listing, Watch everywhere else. */function renderActions() {const p = controller.page();const section = document.getElementById('actions');@@@ -92,17 +92,21 @@const keep = document.getElementById('keep');const watch = document.getElementById('watch');const delivery = document.getElementById('delivery');++ const rating = document.getElementById('rating');const note = document.getElementById('actions-note');-- document.getElementById('actions-lede').textContent = p.delivery++ document.getElementById('actions-lede').textContent = p.review++ ? 'A review site? Bring the rating this page shows to OpenVibe Reviews: it appears beside this site’s link there, with the date. You check the numbers before saving.'++ : p.delivery? 'Ordering delivery? See the same restaurant on every delivery app, with each app’s own prices and fees.': p.rental? 'A rental listing? Keep it on your board with your notes, or check its rent against what the city usually costs.': 'Keep this page on your Rent board, or have OpenVibe.Watch tell you when it changes.';delivery.hidden = !p.delivery;++ rating.hidden = !p.review;// On a rental listing: is its rent fair for the city? (openvibe.rent/fair reads the rent, size and city.)const fair = document.getElementById('fair');fair.hidden = !p.rental;-- (p.delivery ? delivery : p.rental ? keep : watch).classList.add('primary');++ (p.review ? rating : p.delivery ? delivery : p.rental ? keep : watch).classList.add('primary');const go = function (fn) {return async function () {const r = await fn();@@@ -114,6 +118,7 @@fair.addEventListener('click', go(controller.checkRent));watch.addEventListener('click', go(controller.watchPage));delivery.addEventListener('click', go(controller.compareDelivery));++ rating.addEventListener('click', go(controller.saveRating));section.hidden = false;}@@@ -1,6 +1,6 @@{"name": "openvibe-extensions",-- "version": "0.4.0",++ "version": "0.5.0","private": true,"description": "OpenVibe's first-party client surfaces (ADR-023). Today: the OpenVibe Companion, a Manifest V3 browser extension for Chromium and Firefox: coupon codes from OpenVibe.Coupons, and one click to keep a listing on the OpenVibe.Rent board or watch a page with OpenVibe.Watch.","scripts": {@@@ -3,8 +3,9 @@* The permission audit for browser/manifest.json. test/manifest.test.js runs it, and so does* scripts/pack.js before it writes a zip. It fails when anything widens:*-- * permissions exactly activeTab + scripting + storage (scripting runs lib/autoapply.js in the active tab only,-- * when the person presses "Try these codes on this page": activeTab is the only host access)++ * permissions exactly activeTab + scripting + storage (scripting runs lib/autoapply.js or lib/rating.js in the++ * active tab only, when the person presses "Try these codes on this page" or "Save its rating to++ * OpenVibe Reviews": activeTab is the only host access)* host_permissions exactly <API origin>/** forbidden keys content_scripts, background, web_accessible_resources,* externally_connectable, optional_permissions, optional_host_permissions,@@@ -111,7 +111,7 @@ const field = (attrs, labels = []) => ({assert.strictEqual(handoff.deliveryUrl('https://acme.com/pizza', 'Pizza'), null, 'not a delivery app');const p = popup({ url: cases[0][0], title: cases[0][1] });await p.controller.open();-- assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: false, delivery: true });++ assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: false, delivery: true, review: false });assert.deepStrictEqual(p.ext.opened, []);assert.strictEqual((await p.controller.compareDelivery()).ok, true);assert.deepStrictEqual(p.ext.opened, ['https://openvibe.food/delivery?q=Chipotle+Mexican+Grill']);@@@ -61,7 +61,7 @@ const CLEAN = 'https://saltlakecity.craigslist.org/apa/d/sunny-2br/7790000001.htawait p.controller.open();assert.deepStrictEqual(p.ext.opened, [], 'nothing opens on its own');assert.deepStrictEqual(stub.requests.map((r) => r.url), ['/api/v1/merchants/resolve?host=saltlakecity.craigslist.org']);-- assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: true, delivery: false });++ assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: true, delivery: false, review: false });assert.deepStrictEqual(await p.controller.keepOnBoard(), { ok: true });assert.strictEqual(p.ext.opened.length, 1);@@@ -78,7 +78,7 @@ const CLEAN = 'https://saltlakecity.craigslist.org/apa/d/sunny-2br/7790000001.htawait check('pages that are not websites offer no hand-off, and platform.tabs.open refuses anywhere else', async () => {const p = popup({ url: 'chrome://extensions' });await p.controller.open();-- assert.deepStrictEqual(p.controller.page(), { handoff: false, rental: false, delivery: false });++ assert.deepStrictEqual(p.controller.page(), { handoff: false, rental: false, delivery: false, review: false });assert.strictEqual((await p.controller.keepOnBoard()).ok, false);assert.deepStrictEqual(p.ext.opened, []);const platform = createPlatform(fakeExt({}), config);@@@ -90,7 +90,7 @@ const CLEAN = 'https://saltlakecity.craigslist.org/apa/d/sunny-2br/7790000001.htawait check('a shop page that is not a rental leads with Watch', async () => {const p = popup({ url: 'https://acme-outdoor.com/tents/trail-2' });await p.controller.open();-- assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: false, delivery: false });++ assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: false, delivery: false, review: false });});await stub.close();@@@ -83,17 +83,22 @@ const ORIGIN = 'https://openvibe.coupons';}const tabUse = files.filter((f) => /\btabs\.(query|create|update)\b/.test(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(DIR, f));assert.deepStrictEqual(tabUse, [path.join('lib', 'platform.js')], 'only the platform wrapper touches tabs (it opens only hand-off pages)');-- // The page is touched only by "Try these codes on this page": platform.js injects the one packaged file and calls it.++ // The page is touched only by "Try these codes on this page" and "Save its rating to OpenVibe Reviews": platform.js++ // injects the packaged file for that button and calls it.const injects = files.filter((f) => /executeScript|ext\.scripting/.test(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(DIR, f));assert.deepStrictEqual(injects, [path.join('lib', 'platform.js')], 'only the platform wrapper runs a script in a page');const plat = fs.readFileSync(path.join(DIR, 'lib', 'platform.js'), 'utf8');-- assert.deepStrictEqual([...plat.matchAll(/files:\s*\[([^\]]*)\]/g)].map((m) => m[1].trim()), ["'lib/autoapply.js'"], 'the one file it injects');++ assert.deepStrictEqual([...plat.matchAll(/files:\s*\[([^\]]*)\]/g)].map((m) => m[1].trim()), ["'lib/autoapply.js'", "'lib/rating.js'"], 'the two files it injects');assert.doesNotMatch(plat, /allFrames|world:\s*['"]MAIN|tabId:(?!\s*tabId\b)/, 'the active tab only, its top frame, the isolated world');-- // What runs in the shop's page makes no request and reads no stored data.-- const auto = fs.readFileSync(path.join(DIR, 'lib', 'autoapply.js'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');-- for (const re of [/\bfetch\s*\(/, /XMLHttpRequest/, /WebSocket/, /sendBeacon/, /localStorage|sessionStorage|indexedDB/, /document\.cookie/, /\.submit\s*\(|requestSubmit/, /KeyboardEvent|keydown|keypress/, /chrome\.|browser\./]) {-- assert.doesNotMatch(auto, re, `lib/autoapply.js uses ${re}`);++ // What runs in the page makes no request and reads no stored data.++ for (const file of ['autoapply.js', 'rating.js']) {++ const src = fs.readFileSync(path.join(DIR, 'lib', file), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');++ for (const re of [/\bfetch\s*\(/, /XMLHttpRequest/, /WebSocket/, /sendBeacon/, /localStorage|sessionStorage|indexedDB/, /document\.cookie/, /\.submit\s*\(|requestSubmit/, /KeyboardEvent|keydown|keypress/, /chrome\.|browser\./]) {++ assert.doesNotMatch(src, re, `lib/${file} uses ${re}`);++ }}++ // Reading a rating presses nothing and types nothing.++ assert.doesNotMatch(fs.readFileSync(path.join(DIR, 'lib', 'rating.js'), 'utf8'), /\.click\s*\(|dispatchEvent|\.value\s*=|\.focus\s*\(/, 'lib/rating.js only reads');});done();nnew file mode 100644@@@ -0,0 +1,125 @@++'use strict';++/**++ * "Save its rating to OpenVibe Reviews" (browser/lib/rating.js, handoff.reviewUrl, platform.scripting.readRating,++ * controller.saveRating):++ * - the rating a page publishes for search engines (JSON-LD aggregateRating, an EmployerAggregateRating, @graph),++ * and, without it, the stars' own labels ("4.5 star rating", "4.5 of 5 bubbles", "Rated 4.2 out of 5");++ * - which pages are review sites (Google only on Maps; Yelp only on a business page);++ * - the button opens openvibe.reviews/save with the page, its name and the numbers, for the person to check there,++ * and nothing is read before the press; a page that cannot be read still opens the form, without numbers.++ */++const assert = require('assert');++const rating = require('../browser/lib/rating');++const host = require('../browser/lib/host');++const handoffLib = require('../browser/lib/handoff');++const { createApiClient } = require('../browser/lib/api');++const { createPlatform } = require('../browser/lib/platform');++const { createController } = require('../browser/lib/controller');++const config = require('../browser/lib/config');++const { check, done, startStub, fakeExt } = require('./helpers/harness');++++const GD = 'https://www.glassdoor.com/Overview/Working-at-Acme-EI_IE12345.11,15.htm';++const YELP = 'https://www.yelp.com/biz/joes-pizza-new-york?osq=pizza&utm_source=x#reviews';++++(async () => {++ const stub = await startStub();++ const handoff = handoffLib.createHandoff(config);++ const popup = ({ url, title = '', scriptResult = null }) => {++ const ext = fakeExt({ url, title, scriptResult });++ const platform = createPlatform(ext, { ...config, API_ORIGIN: stub.origin });++ const api = createApiClient({ origin: stub.origin, fetch, getToken: platform.storage.getToken });++ return { ext, controller: createController({ api, platform, host, handoff, isRental: handoffLib.isRental }) };++ };++++ await check('numbers as pages print them', () => {++ for (const [s, want] of [['4.5', 4.5], ['4,5', 4.5], ['1,234', 1234], ['1.234', 1234], ['12 345', 12345], [{ '@value': '3.9' }, 3.9], ['n/a', null], ['', null], [null, null]]) {++ assert.strictEqual(rating.num(s), want, JSON.stringify(s));++ }++ });++++ await check('JSON-LD: a restaurant, an employer rating on its own, a @graph, and junk around them', () => {++ const restaurant = JSON.stringify({ '@context': 'https://schema.org', '@type': 'Restaurant', name: "Joe's Pizza", address: { '@type': 'PostalAddress', addressLocality: 'New York', addressRegion: 'NY' },++ aggregateRating: { '@type': 'AggregateRating', ratingValue: '4.5', reviewCount: '9,812' } });++ assert.deepStrictEqual(rating.fromJsonLd(['{not json', '{"@type":"BreadcrumbList"}', restaurant]),++ { name: "Joe's Pizza", value: 4.5, best: 5, count: 9812, place: 'New York, NY', type: 'Restaurant', how: 'structured' });++ const employer = JSON.stringify({ '@context': 'https://schema.org', '@type': 'EmployerAggregateRating', itemReviewed: { '@type': 'Organization', name: 'Acme Corp' }, ratingValue: 3.9, bestRating: 5, ratingCount: 1234 });++ assert.deepStrictEqual(rating.fromJsonLd([employer]), { name: 'Acme Corp', value: 3.9, best: 5, count: 1234, place: '', type: 'Organization', how: 'structured' });++ const graph = JSON.stringify({ '@graph': [{ '@type': 'WebPage' }, { '@type': 'LocalBusiness', name: 'Acme Plumbing', aggregateRating: { ratingValue: '8.6', bestRating: '10', ratingCount: '40' } }] });++ assert.strictEqual(rating.fromJsonLd([graph]).value, 8.6);++ assert.strictEqual(rating.fromJsonLd([graph]).best, 10);++ // A rating above its own scale is not a rating.++ assert.strictEqual(rating.fromJsonLd([JSON.stringify({ name: 'X', aggregateRating: { ratingValue: '7', bestRating: '5' } })]), null);++ });++++ await check('the stars\' labels, in page order, with the count that follows', () => {++ assert.deepStrictEqual(rating.fromLabels(['Close', 'Menu', '4.5 star rating', '1,234 reviews']), { value: 4.5, best: 5, count: 1234, how: 'page' });++ assert.deepStrictEqual(rating.fromLabels(['4.5 of 5 bubbles', '(2,001)']), { value: 4.5, best: 5, count: 2001, how: 'page' });++ assert.deepStrictEqual(rating.fromLabels(['Rated 4.2 out of 5 stars']), { value: 4.2, best: 5, count: null, how: 'page' });++ assert.deepStrictEqual(rating.fromLabels(['4.3 stars', 'Directions', '312 reviews']), { value: 4.3, best: 5, count: 312, how: 'page' });++ assert.strictEqual(rating.fromLabels(['Share', 'Save', '5 photos']), null);++ assert.strictEqual(rating.nameFrom('', "Joe's Pizza - New York, NY - Yelp"), "Joe's Pizza");++ assert.strictEqual(rating.nameFrom('', 'Acme Corp Reviews | Glassdoor'), 'Acme Corp');++ });++++ await check('read(): JSON-LD first, the labels otherwise, from a document', () => {++ const doc = (scripts, labels, h1, title) => ({++ title,++ querySelector: (sel) => (sel === 'h1' && h1 ? { textContent: h1 } : null),++ querySelectorAll: (sel) => (sel.startsWith('script') ? scripts.map((t) => ({ textContent: t })) : labels.map((l) => ({ getAttribute: (k) => (k === 'aria-label' ? l : null) }))),++ });++ assert.strictEqual(rating.read(doc([JSON.stringify({ '@type': 'Organization', name: 'Acme', aggregateRating: { ratingValue: 4.1, ratingCount: 10 } })], ['2 star rating'], 'Acme Corp', '')).value, 4.1);++ assert.deepStrictEqual(rating.read(doc([], ['4 star rating', '88 reviews'], "Joe's Pizza", '')), { value: 4, best: 5, count: 88, how: 'page', name: "Joe's Pizza", place: '', type: '' });++ assert.deepStrictEqual(rating.read(doc([], [], '', 'Acme | Trustpilot')), { name: 'Acme', value: null, best: 5, count: null, place: '', type: '', how: 'none' });++ });++++ await check('review sites: which pages count', () => {++ const yes = [GD, 'https://www.indeed.com/cmp/Acme/reviews', YELP, 'https://www.bbb.org/us/ny/new-york/profile/plumber/acme-0121-1', 'https://www.trustpilot.com/review/acme.com',++ 'https://www.google.com/maps/place/Joe%27s+Pizza/@40.7,-73.9,17z', 'https://www.tripadvisor.com/Restaurant_Review-g60763-d1-Reviews-Joes_Pizza-New_York_City_New_York.html', 'https://www.glassdoor.co.uk/Reviews/Acme-Reviews-E1.htm'];++ for (const u of yes) assert.ok(handoffLib.reviewSite(u), u);++ const no = ['https://www.google.com/search?q=acme', 'https://www.yelp.com/search?find_desc=pizza', 'https://www.indeed.com/jobs?q=x', 'http://www.glassdoor.com/x', 'https://glassdoor.com.evil.example/x', 'https://notyelp.com/biz/x', 'https://acme.com/'];++ for (const u of no) assert.strictEqual(handoffLib.reviewSite(u), null, u);++ });++++ await check('the Reviews address: the page (cleaned), its name, the numbers, the kind from the site or the type', () => {++ const u = new URL(handoff.reviewUrl(YELP, "Joe's Pizza - Yelp", { name: "Joe's Pizza", value: 4.5, best: 5, count: 9812, place: 'New York, NY', type: 'Restaurant' }));++ assert.strictEqual(u.origin + u.pathname, 'https://openvibe.reviews/save');++ assert.deepStrictEqual(Object.fromEntries(u.searchParams), { site: 'yelp', url: 'https://www.yelp.com/biz/joes-pizza-new-york?osq=pizza', name: "Joe's Pizza", kind: 'restaurant', place: 'New York, NY', rating: '4.5', best: '5', count: '9812', via: 'companion' });++ const g = new URL(handoff.reviewUrl(GD, 'Working at Acme | Glassdoor', { name: 'Acme Corp', value: 3.9, best: 5, count: null, type: 'Organization' }));++ assert.strictEqual(g.searchParams.get('kind'), 'employer', 'Glassdoor is about employers whatever the type says');++ assert.strictEqual(g.searchParams.get('count'), null);++ // Unread: the page and its name, no numbers.++ const none = new URL(handoff.reviewUrl('https://www.trustpilot.com/review/acme.com', 'Acme Reviews | Read Customer Service Reviews of acme.com', null));++ assert.deepStrictEqual(Object.fromEntries(none.searchParams), { site: 'trustpilot', url: 'https://www.trustpilot.com/review/acme.com', name: 'Acme Reviews', via: 'companion' });++ assert.strictEqual(handoff.reviewUrl('https://acme.com/', 'Acme', { value: 4 }), null, 'not a review site');++ assert.ok(handoff.isHandoff('https://openvibe.reviews/save?x=1'));++ });++++ await check('the popup: offered on a review site only, reads the page on the press, opens Reviews', async () => {++ const read = { name: "Joe's Pizza", value: 4.5, best: 5, count: 9812, place: 'New York, NY', type: 'Restaurant', how: 'structured' };++ const p = popup({ url: YELP, title: "Joe's Pizza - Yelp", scriptResult: read });++ await p.controller.open();++ assert.strictEqual(p.controller.page().review, true);++ assert.ok(!p.ext.log.some((l) => l[0] === 'scripting.executeScript'), 'nothing runs in the page before the press');++ const r = await p.controller.saveRating();++ assert.deepStrictEqual([r.ok, r.read], [true, true]);++ const injected = p.ext.log.filter((l) => l[0] === 'scripting.executeScript').map((l) => l[1]);++ assert.deepStrictEqual(injected[0], { target: { tabId: 1 }, files: ['lib/rating.js'] });++ assert.deepStrictEqual(injected[1].target, { tabId: 1 }, 'the active tab, its top frame');++ assert.strictEqual(p.ext.opened.length, 1);++ assert.match(p.ext.opened[0], /^https:\/\/openvibe\.reviews\/save\?site=yelp&url=https%3A%2F%2Fwww\.yelp\.com%2Fbiz%2Fjoes-pizza-new-york%3Fosq%3Dpizza&name=Joe%27s\+Pizza&kind=restaurant&place=New\+York%2C\+NY&rating=4\.5&best=5&count=9812&via=companion$/);++ assert.ok(!stub.requests.some((x) => /pizza|biz/i.test([x.url, x.body].join(' '))), 'beyond the hostname (the coupon lookup), the page goes only to Reviews, in the tab the person sees');++++ const unread = popup({ url: 'https://www.trustpilot.com/review/acme.com', title: 'Acme | Trustpilot', scriptResult: null });++ await unread.controller.open();++ const r2 = await unread.controller.saveRating();++ assert.deepStrictEqual([r2.ok, r2.read], [true, false]);++ assert.ok(!/rating=/.test(unread.ext.opened[0]), 'no numbers when the page could not be read');++++ const shop = popup({ url: 'https://shop.acme.com/cart', title: 'Cart' });++ await shop.controller.open();++ assert.strictEqual(shop.controller.page().review, false);++ });++++ await stub.close();++ done();++})();
@@ -42,6 +42,11 @@ One button in the browser for the OpenVibe sites that work with the page you are
@@ -42,6 +42,11 @@ One button in the browser for the OpenVibe sites that work with the page you are
- **Compare delivery apps.** On DoorDash, Uber Eats, Grubhub, Seamless, Caviar or Instacart it opens
- **Compare delivery apps.** On DoorDash, Uber Eats, Grubhub, Seamless, Caviar or Instacart it opens
[openvibe.food/delivery](https://openvibe.food/delivery) with the restaurant or store's name (read from the page
[openvibe.food/delivery](https://openvibe.food/delivery) with the restaurant or store's name (read from the page
title, never the address or the cart), and every app's own search for it opens from there.
title, never the address or the cart), and every app's own search for it opens from there.
+- **Save its rating to OpenVibe Reviews.** On Glassdoor, Indeed, Yelp, the BBB, Trustpilot, Google Maps or
+ Tripadvisor, it reads the rating the page states (the structured data the site publishes for search engines, else
+ the stars' own label, like "4.5 star rating") and opens [openvibe.reviews/save](https://openvibe.reviews/save) with
+ the page's address, its name and the numbers. You check them against the page there and save; the rating then shows
+ beside that site's link on the OpenVibe.Reviews page, with the date. On a review site this is the main button.
- **Try these codes on this page.** At a shop's cart or checkout, one button types the codes OpenVibe.Coupons knows
- **Try these codes on this page.** At a shop's cart or checkout, one button types the codes OpenVibe.Coupons knows
into the page's own coupon box, presses that box's Apply button, and reads the order total before and after. It
into the page's own coupon box, presses that box's Apply button, and reads the order total before and after. It
stops at the first code that lowers the total and leaves it applied. Codes reported working go first, codes
stops at the first code that lowers the total and leaves it applied. Codes reported working go first, codes
@@ -64,11 +69,11 @@ the Companion never rewrites links or adds referral tags.
@@ -64,11 +69,11 @@ the Companion never rewrites links or adds referral tags.
| | |
| | |
|---|---|
|---|---|
-| **Permissions** | `activeTab`, `scripting` and `storage`. The host permission is `https://openvibe.coupons/*` only. `scripting` is used for one thing: "Try these codes on this page" injects the packaged `lib/autoapply.js` into the active tab (the access `activeTab` gives after you press the button), in its top frame and the extension's isolated world. |
+| **Permissions** | `activeTab`, `scripting` and `storage`. The host permission is `https://openvibe.coupons/*` only. `scripting` is used for two buttons: "Try these codes on this page" injects the packaged `lib/autoapply.js`, and "Save its rating to OpenVibe Reviews" injects `lib/rating.js`, into the active tab (the access `activeTab` gives after you press the button), in its top frame and the extension's isolated world. |
| **Not requested** | `<all_urls>`, `tabs`, `cookies`, `webRequest`, `history`, optional permissions, content scripts, a background worker, web-accessible resources, `externally_connectable`. |
| **Not requested** | `<all_urls>`, `tabs`, `cookies`, `webRequest`, `history`, optional permissions, content scripts, a background worker, web-accessible resources, `externally_connectable`. |
-| **Read from the page** | The active tab's **URL and title**, when you open the popup (that's what `activeTab` grants). The page's content only when you press "Try these codes on this page": `lib/autoapply.js` finds the coupon box, its Apply button and the order total, types the codes in, and returns to the popup which codes changed the total. It makes no request, reads no cookies or storage, and sends nothing anywhere; `test/manifest.test.js` scans it for that. Browser pages, files, IP addresses and local names are "not a site", and nothing is sent or offered for them. |
+| **Read from the page** | The active tab's **URL and title**, when you open the popup (that's what `activeTab` grants). The page's content only when you press "Try these codes on this page": `lib/autoapply.js` finds the coupon box, its Apply button and the order total, types the codes in, and returns to the popup which codes changed the total. It makes no request, reads no cookies or storage, and sends nothing anywhere; `test/manifest.test.js` scans it for that. On a review site, "Save its rating to OpenVibe Reviews" runs `lib/rating.js`, which only reads: the page's name, its rating, the scale, how many ratings, and the city from its structured data. Browser pages, files, IP addresses and local names are "not a site", and nothing is sent or offered for them. |
| **Sent by the extension** | To `https://openvibe.coupons` only, and only the **hostname** (`lib/host.js` drops the path, query, fragment, port and credentials first): `GET /api/v1/merchants/resolve?host=`, the merchant id (`GET /api/v1/merchants/:id/coupons`), and, when you report, the code id plus `worked` or `failed` with an optional reason. Every request goes with `credentials: 'omit'` (no cookies), `referrerPolicy: 'no-referrer'` and `redirect: 'error'`. |
| **Sent by the extension** | To `https://openvibe.coupons` only, and only the **hostname** (`lib/host.js` drops the path, query, fragment, port and credentials first): `GET /api/v1/merchants/resolve?host=`, the merchant id (`GET /api/v1/merchants/:id/coupons`), and, when you report, the code id plus `worked` or `failed` with an optional reason. Every request goes with `credentials: 'omit'` (no cookies), `referrerPolicy: 'no-referrer'` and `redirect: 'error'`. |
-| **Hand-offs (only when you press the button)** | "Keep on my Rent board", "Check the rent" and "Watch this page" open **a new tab you see** on `https://openvibe.rent/board`, `https://openvibe.rent/fair` or `https://openvibe.watch/watches/new`, with the page's address and title in that tab's address. `lib/handoff.js` removes the credentials, the fragment and tracking parameters (`utm_*`, `fbclid`, `gclid`, …) first. The extension makes no request for it, nothing opens on its own, and nothing is saved until you save it on that site. `platform.tabs.open()` refuses any other origin, and the permission audit allows only `https://openvibe.<tld>` hand-off origins. |
+| **Hand-offs (only when you press the button)** | "Keep on my Rent board", "Check the rent", "Watch this page", "Compare delivery apps" and "Save its rating to OpenVibe Reviews" open **a new tab you see** on `https://openvibe.rent/board`, `https://openvibe.rent/fair`, `https://openvibe.watch/watches/new`, `https://openvibe.food/delivery` or `https://openvibe.reviews/save`, with the page's address and title (and, for Reviews, the rating read from it) in that tab's address. `lib/handoff.js` removes the credentials, the fragment and tracking parameters (`utm_*`, `fbclid`, `gclid`, …) first. The extension makes no request for it, nothing opens on its own, and nothing is saved until you save it on that site. `platform.tabs.open()` refuses any other origin, and the permission audit allows only `https://openvibe.<tld>` hand-off origins. |
| **Stored** | Only the optional install token, in `storage.local`. It is never synced to other devices. |
| **Stored** | Only the optional install token, in `storage.local`. It is never synced to other devices. |
| **CSP** | `default-src 'none'; script-src 'self'; connect-src https://openvibe.coupons; object-src 'none'`. There is no inline script, no remote code, no `eval`, and no `innerHTML`: the popup builds DOM nodes and sets text. |
| **CSP** | `default-src 'none'; script-src 'self'; connect-src https://openvibe.coupons; object-src 'none'`. There is no inline script, no remote code, no `eval`, and no `innerHTML`: the popup builds DOM nodes and sets text. |
@@ -100,10 +105,11 @@ browser/
@@ -100,10 +105,11 @@ browser/
manifest.json MV3; Chromium (minimum_chrome_version 111) and Firefox (gecko id, strict_min_version 128)
manifest.json MV3; Chromium (minimum_chrome_version 111) and Firefox (gecko id, strict_min_version 128)
popup.html / popup.js DOM wiring only
popup.html / popup.js DOM wiring only
options.html / .js connect (save a checked token), disconnect, grant the API host permission
options.html / .js connect (save a checked token), disconnect, grant the API host permission
- lib/config.js API_ORIGIN (the one origin it sends requests to); HANDOFF_ORIGINS (Rent, Watch)
+ lib/config.js API_ORIGIN (the one origin it sends requests to); HANDOFF_ORIGINS (Rent, Watch, Food, Reviews)
lib/host.js URL → hostname (everything else dropped)
lib/host.js URL → hostname (everything else dropped)
- lib/handoff.js the Rent board, Watch and Food delivery addresses, built from a cleaned URL; which sites are rentals
+ lib/handoff.js the Rent board, Watch, Food delivery and Reviews addresses, built from a cleaned URL; which sites are rentals and review sites
lib/autoapply.js "Try these codes on this page": injected into the active tab only when you press it
lib/autoapply.js "Try these codes on this page": injected into the active tab only when you press it
+ lib/rating.js "Save its rating to OpenVibe Reviews": reads a review site's rating, injected only when you press it
lib/api.js the only code that makes requests
lib/api.js the only code that makes requests
lib/controller.js popup logic without the DOM (tested in Node)
lib/controller.js popup logic without the DOM (tested in Node)
lib/platform.js chrome.* / browser.*: tabs.query, tabs.create (hand-off pages only), storage.local, permissions
lib/platform.js chrome.* / browser.*: tabs.query, tabs.create (hand-off pages only), storage.local, permissions
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
"surfaces": {
"surfaces": {
"browser": {
"browser": {
"name": "OpenVibe Companion",
"name": "OpenVibe Companion",
- "version": "0.3.0",
+ "version": "0.5.0",
"manifest": 3,
"manifest": 3,
"targets": [
"targets": [
"chromium >= 111",
"chromium >= 111",
@@ -17,6 +17,7 @@
@@ -17,6 +17,7 @@
"api": "https://openvibe.coupons (OpenVibe.Coupons public API)",
"api": "https://openvibe.coupons (OpenVibe.Coupons public API)",
"permissions": [
"permissions": [
"activeTab",
"activeTab",
+ "scripting",
"storage"
"storage"
],
],
"hostPermissions": [
"hostPermissions": [
@@ -28,7 +29,10 @@
@@ -28,7 +29,10 @@
"loadedInFirefox": "not yet verified",
"loadedInFirefox": "not yet verified",
"handoffs": [
"handoffs": [
"https://openvibe.rent/board (keep a listing)",
"https://openvibe.rent/board (keep a listing)",
- "https://openvibe.watch/watches/new (watch a page)"
+ "https://openvibe.rent/fair (check the rent)",
+ "https://openvibe.watch/watches/new (watch a page)",
+ "https://openvibe.food/delivery (compare delivery apps)",
+ "https://openvibe.reviews/save (a review site's rating, checked and saved on Reviews)"
]
]
}
}
},
},
@@ -6,7 +6,8 @@
@@ -6,7 +6,8 @@
* a development build (--api http://localhost:4850) and test/manifest.test.js checks they agree.
* a development build (--api http://localhost:4850) and test/manifest.test.js checks they agree.
*
*
* HANDOFF_ORIGINS are the sites the Companion may open a tab on when you press one of its buttons
* HANDOFF_ORIGINS are the sites the Companion may open a tab on when you press one of its buttons
- * (lib/handoff.js): the Rent board, a new Watch, and Food's delivery comparison. It sends them nothing
+ * (lib/handoff.js): the Rent board, a new Watch, Food's delivery comparison, and Reviews' page for saving the rating
+ * a review site shows. It sends them nothing
* itself; the address of the tab it opens carries the page you were on, and you see it.
* itself; the address of the tab it opens carries the page you were on, and you see it.
*
*
* MAX_TRIES is how many codes "Try these codes on this page" types into a shop's coupon box at most
* MAX_TRIES is how many codes "Try these codes on this page" types into a shop's coupon box at most
@@ -23,7 +24,8 @@
@@ -23,7 +24,8 @@
RENT_ORIGIN: 'https://openvibe.rent',
RENT_ORIGIN: 'https://openvibe.rent',
WATCH_ORIGIN: 'https://openvibe.watch',
WATCH_ORIGIN: 'https://openvibe.watch',
FOOD_ORIGIN: 'https://openvibe.food',
FOOD_ORIGIN: 'https://openvibe.food',
- HANDOFF_ORIGINS: Object.freeze(['https://openvibe.rent', 'https://openvibe.watch', 'https://openvibe.food']),
+ REVIEWS_ORIGIN: 'https://openvibe.reviews',
+ HANDOFF_ORIGINS: Object.freeze(['https://openvibe.rent', 'https://openvibe.watch', 'https://openvibe.food', 'https://openvibe.reviews']),
MAX_TRIES: 10,
MAX_TRIES: 10,
CONNECT_PATH: '/connect-extension',
CONNECT_PATH: '/connect-extension',
TOKEN_KEY: 'installToken',
TOKEN_KEY: 'installToken',
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
* await c.checkRent() // is this rent fair? on openvibe.rent/fair (a new tab)
* await c.checkRent() // is this rent fair? on openvibe.rent/fair (a new tab)
* await c.watchPage() // open a new Watch for this page (a new tab)
* await c.watchPage() // open a new Watch for this page (a new tab)
* await c.compareDelivery() // the restaurant on every delivery app, on openvibe.food (a new tab)
* await c.compareDelivery() // the restaurant on every delivery app, on openvibe.food (a new tab)
+ * await c.saveRating() // a review site's rating (lib/rating.js reads it) → openvibe.reviews/save (a new tab)
* await c.tryCodes(coupons) // type the codes into this page's coupon box (lib/autoapply.js) → a view of it
* await c.tryCodes(coupons) // type the codes into this page's coupon box (lib/autoapply.js) → a view of it
* await c.report(couponId, 'worked') // → { ok, message }
* await c.report(couponId, 'worked') // → { ok, message }
*
*
@@ -81,6 +82,7 @@
@@ -81,6 +82,7 @@
handoff: ok,
handoff: ok,
rental: ok && deps.isRental ? deps.isRental(tab.url) : false,
rental: ok && deps.isRental ? deps.isRental(tab.url) : false,
delivery: ok && Boolean(handoff.deliveryUrl && handoff.deliveryUrl(tab.url, tab.title)),
delivery: ok && Boolean(handoff.deliveryUrl && handoff.deliveryUrl(tab.url, tab.title)),
+ review: ok && Boolean(handoff.reviewUrl && handoff.reviewUrl(tab.url, tab.title, null)),
};
};
},
},
@@ -89,6 +91,18 @@
@@ -89,6 +91,18 @@
watchPage() { return handOff(function (u, t) { return handoff.watchUrl(u, t); }); },
watchPage() { return handOff(function (u, t) { return handoff.watchUrl(u, t); }); },
compareDelivery() { return handOff(function (u, t) { return handoff.deliveryUrl(u, t); }); },
compareDelivery() { return handOff(function (u, t) { return handoff.deliveryUrl(u, t); }); },
+ /**
+ * Read the rating this review site's page states (lib/rating.js, injected on this press) and open
+ * openvibe.reviews/save with it for the person to check. When the page cannot be read, Reviews' form opens
+ * with the page's address and name, for them to type the numbers in.
+ */
+ async saveRating() {
+ let rating = null;
+ try { rating = await platform.scripting.readRating(tab.id); } catch (e) { rating = null; }
+ const r = await handOff(function (u, t) { return handoff.reviewUrl(u, t, rating); });
+ return r.ok ? Object.assign(r, { read: Boolean(rating && typeof rating.value === 'number') }) : r;
+ },
+
/**
/**
* Try the codes in this page's own coupon box. → { ok, message, results: [{ id, code, outcome, saved }] }.
* Try the codes in this page's own coupon box. → { ok, message, results: [{ id, code, outcome, saved }] }.
* Codes reported working come first; at most config.MAX_TRIES.
* Codes reported working come first; at most config.MAX_TRIES.
@@ -9,6 +9,9 @@
@@ -9,6 +9,9 @@
* deliveryUrl(url, title) https://openvibe.food/delivery?q=… the same restaurant on every delivery app
* deliveryUrl(url, title) https://openvibe.food/delivery?q=… the same restaurant on every delivery app
* (on a DoorDash, Uber Eats, Grubhub, Seamless, Caviar or Instacart page: the restaurant or
* (on a DoorDash, Uber Eats, Grubhub, Seamless, Caviar or Instacart page: the restaurant or
* store name read from the page title, never the address or the cart)
* store name read from the page title, never the address or the cart)
+ * reviewUrl(url, title, rating) https://openvibe.reviews/save?site=…&url=…&name=…&rating=… the rating a review
+ * site's page shows (Glassdoor, Indeed, Yelp, the BBB, Trustpilot, Google Maps, Tripadvisor;
+ * read by lib/rating.js when the person presses the button), for them to check and save there
*
*
* This is the only code that uses more of the page than its hostname, and what it builds is a page
* This is the only code that uses more of the page than its hostname, and what it builds is a page
* address opened in a new tab you see, never a request the extension makes. cleanUrl() drops the
* address opened in a new tab you see, never a request the extension makes. cleanUrl() drops the
@@ -52,6 +55,50 @@
@@ -52,6 +55,50 @@
function isDelivery(url) { return Boolean(deliveryApp(url)); }
function isDelivery(url) { return Boolean(deliveryApp(url)); }
+ /** Review sites whose pages state a rating OpenVibe.Reviews shows beside their link (its SEEN_SITES). */
+ const REVIEW_SITES = [
+ { site: 'glassdoor', host: /(^|\.)glassdoor\.[a-z.]{2,12}$/, kind: 'employer' },
+ { site: 'indeed', host: /(^|\.)indeed\.[a-z.]{2,12}$/, kind: 'employer', path: /^\/cmp\// },
+ { site: 'yelp', host: /(^|\.)yelp\.[a-z.]{2,12}$/, path: /^\/biz\// },
+ { site: 'bbb', host: /(^|\.)bbb\.org$/, kind: 'business' },
+ { site: 'trustpilot', host: /(^|\.)trustpilot\.com$/, path: /^\/review\// },
+ { site: 'google', host: /(^|\.)google\.[a-z.]{2,12}$/, path: /^\/maps\b/ },
+ { site: 'tripadvisor', host: /(^|\.)tripadvisor\.[a-z.]{2,12}$/ },
+ ];
+ const TYPE_KIND = [[/restaurant|food|cafe|bakery|bar(?:Or)?|pub|winery|brewery/i, 'restaurant'], [/hotel|lodging|attraction|landmark|park|museum/i, 'place'],
+ [/localbusiness|store|shop|dentist|medical|automotive|homeandconstruction|legal|financial/i, 'business'], [/softwareapplication|mobileapplication|webapplication/i, 'app'],
+ [/organization|corporation/i, 'organization']];
+
+ /** Which review site a page is on, or null (Google only on its Maps pages). */
+ function reviewSite(url) {
+ let u;
+ try { u = new URL(String(url)); } catch (e) { return null; }
+ if (u.protocol !== 'https:') return null;
+ const h = u.hostname.toLowerCase();
+ const r = REVIEW_SITES.find(function (x) { return x.host.test(h) && (!x.path || x.path.test(u.pathname) || (x.site === 'google' && /^maps\./.test(h))); });
+ return r || null;
+ }
+
+ function reviewUrl(REVIEWS, url, title, rating) {
+ const r = reviewSite(url);
+ const page = r && cleanUrl(url);
+ if (!page) return null;
+ const got = rating || {};
+ const q = new URLSearchParams({ site: r.site, url: page });
+ const name = cleanTitle(got.name, 200) || cleanTitle(String(title || '').split(/\s+[|\-\u2013\u2014]\s+/)[0], 200);
+ if (name) q.set('name', name);
+ const kind = r.kind || (TYPE_KIND.find(function (k) { return k[0].test(String(got.type || '')); }) || [])[1];
+ if (kind) q.set('kind', kind);
+ if (got.place) q.set('place', cleanTitle(got.place, 120));
+ if (typeof got.value === 'number' && Number.isFinite(got.value)) {
+ q.set('rating', String(got.value));
+ q.set('best', String(typeof got.best === 'number' && got.best > 0 ? got.best : 5));
+ if (Number.isInteger(got.count)) q.set('count', String(got.count));
+ }
+ q.set('via', 'companion');
+ return REVIEWS + '/save?' + q.toString();
+ }
+
/** The restaurant or store a delivery app's page is about, from its title; '' when the title names none. */
/** The restaurant or store a delivery app's page is about, from its title; '' when the title names none. */
function deliveryQuery(url, title) {
function deliveryQuery(url, title) {
const app = deliveryApp(url);
const app = deliveryApp(url);
@@ -95,6 +142,7 @@
@@ -95,6 +142,7 @@
const RENT = config.RENT_ORIGIN;
const RENT = config.RENT_ORIGIN;
const WATCH = config.WATCH_ORIGIN;
const WATCH = config.WATCH_ORIGIN;
const FOOD = config.FOOD_ORIGIN;
const FOOD = config.FOOD_ORIGIN;
+ const REVIEWS = config.REVIEWS_ORIGIN;
const origins = config.HANDOFF_ORIGINS;
const origins = config.HANDOFF_ORIGINS;
return {
return {
boardUrl: function (url, title) { return boardUrl(RENT, url, title); },
boardUrl: function (url, title) { return boardUrl(RENT, url, title); },
@@ -105,6 +153,7 @@
@@ -105,6 +153,7 @@
const q = deliveryQuery(url, title);
const q = deliveryQuery(url, title);
return FOOD + '/delivery' + (q ? '?' + new URLSearchParams({ q: q }).toString() : '');
return FOOD + '/delivery' + (q ? '?' + new URLSearchParams({ q: q }).toString() : '');
},
},
+ reviewUrl: function (url, title, rating) { return REVIEWS ? reviewUrl(REVIEWS, url, title, rating) : null; },
isHandoff: function (url) { return isHandoff(origins, url); },
isHandoff: function (url) { return isHandoff(origins, url); },
};
};
}
}
@@ -141,5 +190,5 @@
@@ -141,5 +190,5 @@
return typeof url === 'string' && origins.some(function (o) { return url.indexOf(o + '/') === 0; });
return typeof url === 'string' && origins.some(function (o) { return url.indexOf(o + '/') === 0; });
}
}
- return { createHandoff, RENTAL_HOSTS, cleanUrl, isRental, isHandoff, isDelivery, deliveryQuery };
+ return { createHandoff, RENTAL_HOSTS, REVIEW_SITES, cleanUrl, isRental, isHandoff, isDelivery, deliveryQuery, reviewSite };
});
});
@@ -13,6 +13,8 @@
@@ -13,6 +13,8 @@
* scripting.tryCodes(tabId, codes) when the person presses "Try these codes on this page": inject
* scripting.tryCodes(tabId, codes) when the person presses "Try these codes on this page": inject
* lib/autoapply.js into that tab (activeTab grants it for this one tab, after
* lib/autoapply.js into that tab (activeTab grants it for this one tab, after
* that click) and run it with the codes; returns what it saw
* that click) and run it with the codes; returns what it saw
+ * scripting.readRating(tabId) when the person presses "Save its rating to OpenVibe Reviews" on a review
+ * site: inject lib/rating.js the same way and return the rating the page states
*
*
* Nothing else: no cookies, no history, and the page is touched only by that one button.
* Nothing else: no cookies, no history, and the page is touched only by that one button.
*/
*/
@@ -54,6 +56,12 @@
@@ -54,6 +56,12 @@
}]);
}]);
return out && out[0] ? out[0].result : null;
return out && out[0] ? out[0].result : null;
},
},
+ async readRating(tabId) {
+ if (!ext.scripting || !Number.isInteger(tabId)) throw new TypeError('this browser cannot read the page');
+ await call(ext.scripting.executeScript, ext.scripting, [{ target: { tabId: tabId }, files: ['lib/rating.js'] }]);
+ const out = await call(ext.scripting.executeScript, ext.scripting, [{ target: { tabId: tabId }, func: function () { return self.OVRating.read(document); } }]);
+ return out && out[0] ? out[0].result : null;
+ },
},
},
storage: {
storage: {
async getToken() {
async getToken() {
new file mode 100644
new file mode 100644
@@ -0,0 +1,163 @@
@@ -0,0 +1,163 @@
+/*
+ * Read the rating a review site's page states: the OpenVibe Companion's "Save its rating to OpenVibe Reviews".
+ *
+ * Injected into the active tab only when the person presses that button in the popup (activeTab + scripting, as
+ * lib/autoapply.js is), on Glassdoor, Indeed, Yelp, the BBB, Trustpilot, Google Maps or Tripadvisor. It reads the
+ * page's DOM in the extension's isolated world, makes no request, reads no cookies or storage, and returns only:
+ *
+ * read(document) → { name, value, best, count, place, type, how } | null
+ * name what the page is about (its structured data's name, else its heading)
+ * value the rating, on the site's own scale; best: the top of that scale (5 when the page does not say)
+ * count how many ratings it is from, or null
+ * place "City, Region" from the structured data's address, or ''
+ * type the structured data's @type (Restaurant, Organization…), or ''
+ * how 'structured' (JSON-LD or microdata the site publishes for search engines) or 'page' (the stars' own
+ * accessible label, like "4.5 star rating")
+ *
+ * The popup opens openvibe.reviews/save with these values; the person checks them against the page there before
+ * anything is saved.
+ */
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api;
+ else root.OVRating = api;
+})(typeof self !== 'undefined' ? self : this, function () {
+ 'use strict';
+
+ function num(v) {
+ if (v == null) return null;
+ const s = String(typeof v === 'object' && v['@value'] != null ? v['@value'] : v).trim().replace(/\s/g, '');
+ // "1,234" and "1.234" (thousands) as counts; "4,5" (decimal comma) as a rating.
+ const m = /^(\d{1,3}(?:[,.]\d{3})+|\d+(?:[.,]\d+)?)$/.exec(s);
+ if (!m) return null;
+ const t = /^\d{1,3}(?:[,.]\d{3})+$/.test(m[1]) ? m[1].replace(/[,.]/g, '') : m[1].replace(',', '.');
+ const n = Number(t);
+ return Number.isFinite(n) ? n : null;
+ }
+
+ function text(v, max) {
+ return String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max || 200);
+ }
+
+ function typeOf(o) {
+ const t = o && o['@type'];
+ return text(Array.isArray(t) ? t[0] : t, 60);
+ }
+
+ function placeOf(o) {
+ const a = o && (Array.isArray(o.address) ? o.address[0] : o.address);
+ if (!a) return '';
+ if (typeof a === 'string') return text(a, 120);
+ const city = text(a.addressLocality, 60);
+ const region = text(a.addressRegion, 40) || text(typeof a.addressCountry === 'object' && a.addressCountry ? a.addressCountry.name : a.addressCountry, 40);
+ return [city, region].filter(Boolean).join(', ');
+ }
+
+ function ratingOf(owner, r) {
+ const value = num(r.ratingValue);
+ const best = num(r.bestRating) || 5;
+ if (value == null || best <= 0 || value > best) return null;
+ const count = num(r.ratingCount) != null ? num(r.ratingCount) : num(r.reviewCount);
+ return { name: text(owner && owner.name, 200), value, best, count: count != null && Number.isInteger(count) ? count : null, place: placeOf(owner), type: typeOf(owner), how: 'structured' };
+ }
+
+ /** The first aggregateRating in a page's JSON-LD blocks (their text), with what it rates; null when none. */
+ function fromJsonLd(blocks) {
+ const seen = [];
+ const walk = function (o, depth) {
+ if (!o || typeof o !== 'object' || depth > 6 || seen.length > 2000) return null;
+ seen.push(o);
+ if (Array.isArray(o)) {
+ for (const x of o) { const r = walk(x, depth + 1); if (r) return r; }
+ return null;
+ }
+ if (o.aggregateRating && typeof o.aggregateRating === 'object') {
+ const r = ratingOf(o.itemReviewed || o, Array.isArray(o.aggregateRating) ? o.aggregateRating[0] : o.aggregateRating);
+ if (r) return r;
+ }
+ // An AggregateRating (or EmployerAggregateRating) on its own, naming what it rates.
+ if (/AggregateRating$/.test(typeOf(o)) && o.ratingValue != null) {
+ const r = ratingOf(o.itemReviewed || {}, o);
+ if (r) return r;
+ }
+ for (const k of ['@graph', 'mainEntity', 'itemReviewed', 'about']) {
+ if (o[k]) { const r = walk(o[k], depth + 1); if (r) return r; }
+ }
+ return null;
+ };
+ for (const b of blocks || []) {
+ let data;
+ try { data = JSON.parse(String(b || '')); } catch (e) { continue; }
+ const r = walk(data, 0);
+ if (r) return r;
+ }
+ return null;
+ }
+
+ // The stars' accessible labels: "4.5 star rating" (Yelp), "4.5 stars" (Google Maps), "4.5 of 5 bubbles"
+ // (Tripadvisor), "Rated 4.2 out of 5" (Trustpilot, Indeed), "3.9 out of 5 stars" (Glassdoor).
+ const LABEL_RES = [
+ /^\s*rated\s+(\d+(?:[.,]\d+)?)\s+(?:out\s+)?of\s+(\d+)/i,
+ /^\s*(\d+(?:[.,]\d+)?)\s*(?:out\s+of|of|\/)\s*(\d+)(?:\s*(?:stars?|bubbles?|points?))?\b/i,
+ /^\s*(\d+(?:[.,]\d+)?)\s*(?:-|\s)?\s*stars?(?:\s+rating)?\s*$/i,
+ /^\s*(\d+(?:[.,]\d+)?)\s+bubbles?\b/i,
+ ];
+ const COUNT_RE = /^\s*\(?\s*(\d[\d,.\s]*)\s*\)?\s*(?:reviews?|ratings?|bewertungen|avis)?\s*$/i;
+
+ /** A rating from the stars' labels, in page order; the count from the first "1,234 reviews" label after it. */
+ function fromLabels(labels) {
+ const list = (labels || []).map(function (l) { return text(l, 120); }).filter(Boolean);
+ for (let i = 0; i < list.length; i++) {
+ for (const re of LABEL_RES) {
+ const m = re.exec(list[i]);
+ if (!m) continue;
+ const value = num(m[1]);
+ const best = m[2] ? num(m[2]) : 5;
+ if (value == null || !best || value > best) continue;
+ let count = null;
+ for (let j = i + 1; j < Math.min(list.length, i + 6) && count == null; j++) {
+ if (/\b(reviews?|ratings?)\b/i.test(list[j]) || /^\(\s*\d/.test(list[j])) {
+ const c = COUNT_RE.exec(list[j]);
+ if (c) count = num(c[1].replace(/\s/g, ''));
+ }
+ }
+ return { value, best, count: count != null && Number.isInteger(count) ? count : null, how: 'page' };
+ }
+ }
+ return null;
+ }
+
+ /** The page's own name for itself: its heading, else its title without the site's suffix. */
+ function nameFrom(h1, title) {
+ const h = text(h1, 200);
+ if (h) return h;
+ return text(String(title || '').split(/\s+[|\-–—]\s+/)[0].replace(/\s+(Reviews?|Employee Reviews|Company Reviews)$/i, ''), 200);
+ }
+
+ /** Microdata (itemprop="aggregateRating") as JSON-LD would say it. */
+ function fromMicrodata(doc) {
+ const box = doc.querySelector('[itemprop="aggregateRating"]');
+ if (!box) return null;
+ const prop = function (el, name) {
+ const n = el.querySelector('[itemprop="' + name + '"]');
+ return n ? (n.getAttribute('content') || n.textContent) : null;
+ };
+ const scope = box.parentElement && box.parentElement.closest ? box.parentElement.closest('[itemscope]') : null;
+ const r = ratingOf({ name: scope ? prop(scope, 'name') : '' }, { ratingValue: prop(box, 'ratingValue'), bestRating: prop(box, 'bestRating'), ratingCount: prop(box, 'ratingCount'), reviewCount: prop(box, 'reviewCount') });
+ return r;
+ }
+
+ function read(doc) {
+ doc = doc || document;
+ const blocks = Array.prototype.map.call(doc.querySelectorAll('script[type="application/ld+json"]'), function (s) { return s.textContent; }).slice(0, 40);
+ const h1 = doc.querySelector('h1');
+ const name = nameFrom(h1 && h1.textContent, doc.title);
+ const found = fromJsonLd(blocks) || fromMicrodata(doc);
+ if (found) return Object.assign(found, { name: found.name || name });
+ const labels = Array.prototype.map.call(doc.querySelectorAll('[aria-label], [title]'), function (el) { return el.getAttribute('aria-label') || el.getAttribute('title'); }).slice(0, 3000);
+ const page = fromLabels(labels);
+ return page ? Object.assign(page, { name: name, place: '', type: '' }) : (name ? { name: name, value: null, best: 5, count: null, place: '', type: '', how: 'none' } : null);
+ }
+
+ return { read, fromJsonLd, fromLabels, nameFrom, num };
+});
@@ -2,8 +2,8 @@
@@ -2,8 +2,8 @@
"manifest_version": 3,
"manifest_version": 3,
"name": "OpenVibe Companion",
"name": "OpenVibe Companion",
"short_name": "OpenVibe",
"short_name": "OpenVibe",
- "version": "0.4.0",
- "description": "Coupon codes for the shop you are on, tried in its coupon box when you ask; the same restaurant on every delivery app; and one click to keep a rental listing on your OpenVibe.Rent board or watch a page. Reads the tab only when you open it.",
+ "version": "0.5.0",
+ "description": "Coupon codes for the shop you are on, tried in its coupon box when you ask; the same restaurant on every delivery app; a review site's rating saved to OpenVibe.Reviews; and one click to keep a rental listing on your OpenVibe.Rent board or watch a page. Reads the tab only when you open it.",
"homepage_url": "https://github.com/OpenVibers/OpenVibe.Extensions",
"homepage_url": "https://github.com/OpenVibers/OpenVibe.Extensions",
"author": "OpenVibers",
"author": "OpenVibers",
"icons": {
"icons": {
@@ -17,8 +17,9 @@
@@ -17,8 +17,9 @@
<button type="button" id="fair" class="btn" hidden>Check the rent</button>
<button type="button" id="fair" class="btn" hidden>Check the rent</button>
<button type="button" id="watch" class="btn">Watch this page</button>
<button type="button" id="watch" class="btn">Watch this page</button>
<button type="button" id="delivery" class="btn" hidden>Compare delivery apps</button>
<button type="button" id="delivery" class="btn" hidden>Compare delivery apps</button>
+ <button type="button" id="rating" class="btn" hidden>Save its rating to OpenVibe Reviews</button>
</div>
</div>
- <p id="actions-note" class="note muted">Opens openvibe.rent, openvibe.watch or openvibe.food in a new tab with this page’s address.</p>
+ <p id="actions-note" class="note muted">Opens openvibe.rent, openvibe.watch, openvibe.food or openvibe.reviews in a new tab with this page’s address.</p>
</section>
</section>
<main id="content" aria-live="polite"><p class="muted">Looking up this shop…</p></main>
<main id="content" aria-live="polite"><p class="muted">Looking up this shop…</p></main>
<footer>
<footer>
@@ -84,7 +84,7 @@
@@ -84,7 +84,7 @@
note);
note);
}
}
- /** The two hand-off buttons: the board leads on a rental listing, Watch everywhere else. */
+ /** The hand-off buttons: the rating leads on a review site, the comparison on a delivery app, the board on a rental listing, Watch everywhere else. */
function renderActions() {
function renderActions() {
const p = controller.page();
const p = controller.page();
const section = document.getElementById('actions');
const section = document.getElementById('actions');
@@ -92,17 +92,21 @@
@@ -92,17 +92,21 @@
const keep = document.getElementById('keep');
const keep = document.getElementById('keep');
const watch = document.getElementById('watch');
const watch = document.getElementById('watch');
const delivery = document.getElementById('delivery');
const delivery = document.getElementById('delivery');
+ const rating = document.getElementById('rating');
const note = document.getElementById('actions-note');
const note = document.getElementById('actions-note');
- document.getElementById('actions-lede').textContent = p.delivery
+ document.getElementById('actions-lede').textContent = p.review
+ ? 'A review site? Bring the rating this page shows to OpenVibe Reviews: it appears beside this site’s link there, with the date. You check the numbers before saving.'
+ : p.delivery
? 'Ordering delivery? See the same restaurant on every delivery app, with each app’s own prices and fees.'
? 'Ordering delivery? See the same restaurant on every delivery app, with each app’s own prices and fees.'
: p.rental
: p.rental
? 'A rental listing? Keep it on your board with your notes, or check its rent against what the city usually costs.'
? 'A rental listing? Keep it on your board with your notes, or check its rent against what the city usually costs.'
: 'Keep this page on your Rent board, or have OpenVibe.Watch tell you when it changes.';
: 'Keep this page on your Rent board, or have OpenVibe.Watch tell you when it changes.';
delivery.hidden = !p.delivery;
delivery.hidden = !p.delivery;
+ rating.hidden = !p.review;
// On a rental listing: is its rent fair for the city? (openvibe.rent/fair reads the rent, size and city.)
// On a rental listing: is its rent fair for the city? (openvibe.rent/fair reads the rent, size and city.)
const fair = document.getElementById('fair');
const fair = document.getElementById('fair');
fair.hidden = !p.rental;
fair.hidden = !p.rental;
- (p.delivery ? delivery : p.rental ? keep : watch).classList.add('primary');
+ (p.review ? rating : p.delivery ? delivery : p.rental ? keep : watch).classList.add('primary');
const go = function (fn) {
const go = function (fn) {
return async function () {
return async function () {
const r = await fn();
const r = await fn();
@@ -114,6 +118,7 @@
@@ -114,6 +118,7 @@
fair.addEventListener('click', go(controller.checkRent));
fair.addEventListener('click', go(controller.checkRent));
watch.addEventListener('click', go(controller.watchPage));
watch.addEventListener('click', go(controller.watchPage));
delivery.addEventListener('click', go(controller.compareDelivery));
delivery.addEventListener('click', go(controller.compareDelivery));
+ rating.addEventListener('click', go(controller.saveRating));
section.hidden = false;
section.hidden = false;
}
}
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-extensions",
"name": "openvibe-extensions",
- "version": "0.4.0",
+ "version": "0.5.0",
"private": true,
"private": true,
"description": "OpenVibe's first-party client surfaces (ADR-023). Today: the OpenVibe Companion, a Manifest V3 browser extension for Chromium and Firefox: coupon codes from OpenVibe.Coupons, and one click to keep a listing on the OpenVibe.Rent board or watch a page with OpenVibe.Watch.",
"description": "OpenVibe's first-party client surfaces (ADR-023). Today: the OpenVibe Companion, a Manifest V3 browser extension for Chromium and Firefox: coupon codes from OpenVibe.Coupons, and one click to keep a listing on the OpenVibe.Rent board or watch a page with OpenVibe.Watch.",
"scripts": {
"scripts": {
@@ -3,8 +3,9 @@
@@ -3,8 +3,9 @@
* The permission audit for browser/manifest.json. test/manifest.test.js runs it, and so does
* The permission audit for browser/manifest.json. test/manifest.test.js runs it, and so does
* scripts/pack.js before it writes a zip. It fails when anything widens:
* scripts/pack.js before it writes a zip. It fails when anything widens:
*
*
- * permissions exactly activeTab + scripting + storage (scripting runs lib/autoapply.js in the active tab only,
- * when the person presses "Try these codes on this page": activeTab is the only host access)
+ * permissions exactly activeTab + scripting + storage (scripting runs lib/autoapply.js or lib/rating.js in the
+ * active tab only, when the person presses "Try these codes on this page" or "Save its rating to
+ * OpenVibe Reviews": activeTab is the only host access)
* host_permissions exactly <API origin>/*
* host_permissions exactly <API origin>/*
* forbidden keys content_scripts, background, web_accessible_resources,
* forbidden keys content_scripts, background, web_accessible_resources,
* externally_connectable, optional_permissions, optional_host_permissions,
* externally_connectable, optional_permissions, optional_host_permissions,
@@ -111,7 +111,7 @@ const field = (attrs, labels = []) => ({
@@ -111,7 +111,7 @@ const field = (attrs, labels = []) => ({
assert.strictEqual(handoff.deliveryUrl('https://acme.com/pizza', 'Pizza'), null, 'not a delivery app');
assert.strictEqual(handoff.deliveryUrl('https://acme.com/pizza', 'Pizza'), null, 'not a delivery app');
const p = popup({ url: cases[0][0], title: cases[0][1] });
const p = popup({ url: cases[0][0], title: cases[0][1] });
await p.controller.open();
await p.controller.open();
- assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: false, delivery: true });
+ assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: false, delivery: true, review: false });
assert.deepStrictEqual(p.ext.opened, []);
assert.deepStrictEqual(p.ext.opened, []);
assert.strictEqual((await p.controller.compareDelivery()).ok, true);
assert.strictEqual((await p.controller.compareDelivery()).ok, true);
assert.deepStrictEqual(p.ext.opened, ['https://openvibe.food/delivery?q=Chipotle+Mexican+Grill']);
assert.deepStrictEqual(p.ext.opened, ['https://openvibe.food/delivery?q=Chipotle+Mexican+Grill']);
@@ -61,7 +61,7 @@ const CLEAN = 'https://saltlakecity.craigslist.org/apa/d/sunny-2br/7790000001.ht
@@ -61,7 +61,7 @@ const CLEAN = 'https://saltlakecity.craigslist.org/apa/d/sunny-2br/7790000001.ht
await p.controller.open();
await p.controller.open();
assert.deepStrictEqual(p.ext.opened, [], 'nothing opens on its own');
assert.deepStrictEqual(p.ext.opened, [], 'nothing opens on its own');
assert.deepStrictEqual(stub.requests.map((r) => r.url), ['/api/v1/merchants/resolve?host=saltlakecity.craigslist.org']);
assert.deepStrictEqual(stub.requests.map((r) => r.url), ['/api/v1/merchants/resolve?host=saltlakecity.craigslist.org']);
- assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: true, delivery: false });
+ assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: true, delivery: false, review: false });
assert.deepStrictEqual(await p.controller.keepOnBoard(), { ok: true });
assert.deepStrictEqual(await p.controller.keepOnBoard(), { ok: true });
assert.strictEqual(p.ext.opened.length, 1);
assert.strictEqual(p.ext.opened.length, 1);
@@ -78,7 +78,7 @@ const CLEAN = 'https://saltlakecity.craigslist.org/apa/d/sunny-2br/7790000001.ht
@@ -78,7 +78,7 @@ const CLEAN = 'https://saltlakecity.craigslist.org/apa/d/sunny-2br/7790000001.ht
await check('pages that are not websites offer no hand-off, and platform.tabs.open refuses anywhere else', async () => {
await check('pages that are not websites offer no hand-off, and platform.tabs.open refuses anywhere else', async () => {
const p = popup({ url: 'chrome://extensions' });
const p = popup({ url: 'chrome://extensions' });
await p.controller.open();
await p.controller.open();
- assert.deepStrictEqual(p.controller.page(), { handoff: false, rental: false, delivery: false });
+ assert.deepStrictEqual(p.controller.page(), { handoff: false, rental: false, delivery: false, review: false });
assert.strictEqual((await p.controller.keepOnBoard()).ok, false);
assert.strictEqual((await p.controller.keepOnBoard()).ok, false);
assert.deepStrictEqual(p.ext.opened, []);
assert.deepStrictEqual(p.ext.opened, []);
const platform = createPlatform(fakeExt({}), config);
const platform = createPlatform(fakeExt({}), config);
@@ -90,7 +90,7 @@ const CLEAN = 'https://saltlakecity.craigslist.org/apa/d/sunny-2br/7790000001.ht
@@ -90,7 +90,7 @@ const CLEAN = 'https://saltlakecity.craigslist.org/apa/d/sunny-2br/7790000001.ht
await check('a shop page that is not a rental leads with Watch', async () => {
await check('a shop page that is not a rental leads with Watch', async () => {
const p = popup({ url: 'https://acme-outdoor.com/tents/trail-2' });
const p = popup({ url: 'https://acme-outdoor.com/tents/trail-2' });
await p.controller.open();
await p.controller.open();
- assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: false, delivery: false });
+ assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: false, delivery: false, review: false });
});
});
await stub.close();
await stub.close();
@@ -83,17 +83,22 @@ const ORIGIN = 'https://openvibe.coupons';
@@ -83,17 +83,22 @@ const ORIGIN = 'https://openvibe.coupons';
}
}
const tabUse = files.filter((f) => /\btabs\.(query|create|update)\b/.test(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(DIR, f));
const tabUse = files.filter((f) => /\btabs\.(query|create|update)\b/.test(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(DIR, f));
assert.deepStrictEqual(tabUse, [path.join('lib', 'platform.js')], 'only the platform wrapper touches tabs (it opens only hand-off pages)');
assert.deepStrictEqual(tabUse, [path.join('lib', 'platform.js')], 'only the platform wrapper touches tabs (it opens only hand-off pages)');
- // The page is touched only by "Try these codes on this page": platform.js injects the one packaged file and calls it.
+ // The page is touched only by "Try these codes on this page" and "Save its rating to OpenVibe Reviews": platform.js
+ // injects the packaged file for that button and calls it.
const injects = files.filter((f) => /executeScript|ext\.scripting/.test(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(DIR, f));
const injects = files.filter((f) => /executeScript|ext\.scripting/.test(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(DIR, f));
assert.deepStrictEqual(injects, [path.join('lib', 'platform.js')], 'only the platform wrapper runs a script in a page');
assert.deepStrictEqual(injects, [path.join('lib', 'platform.js')], 'only the platform wrapper runs a script in a page');
const plat = fs.readFileSync(path.join(DIR, 'lib', 'platform.js'), 'utf8');
const plat = fs.readFileSync(path.join(DIR, 'lib', 'platform.js'), 'utf8');
- assert.deepStrictEqual([...plat.matchAll(/files:\s*\[([^\]]*)\]/g)].map((m) => m[1].trim()), ["'lib/autoapply.js'"], 'the one file it injects');
+ assert.deepStrictEqual([...plat.matchAll(/files:\s*\[([^\]]*)\]/g)].map((m) => m[1].trim()), ["'lib/autoapply.js'", "'lib/rating.js'"], 'the two files it injects');
assert.doesNotMatch(plat, /allFrames|world:\s*['"]MAIN|tabId:(?!\s*tabId\b)/, 'the active tab only, its top frame, the isolated world');
assert.doesNotMatch(plat, /allFrames|world:\s*['"]MAIN|tabId:(?!\s*tabId\b)/, 'the active tab only, its top frame, the isolated world');
- // What runs in the shop's page makes no request and reads no stored data.
- const auto = fs.readFileSync(path.join(DIR, 'lib', 'autoapply.js'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
- for (const re of [/\bfetch\s*\(/, /XMLHttpRequest/, /WebSocket/, /sendBeacon/, /localStorage|sessionStorage|indexedDB/, /document\.cookie/, /\.submit\s*\(|requestSubmit/, /KeyboardEvent|keydown|keypress/, /chrome\.|browser\./]) {
- assert.doesNotMatch(auto, re, `lib/autoapply.js uses ${re}`);
+ // What runs in the page makes no request and reads no stored data.
+ for (const file of ['autoapply.js', 'rating.js']) {
+ const src = fs.readFileSync(path.join(DIR, 'lib', file), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
+ for (const re of [/\bfetch\s*\(/, /XMLHttpRequest/, /WebSocket/, /sendBeacon/, /localStorage|sessionStorage|indexedDB/, /document\.cookie/, /\.submit\s*\(|requestSubmit/, /KeyboardEvent|keydown|keypress/, /chrome\.|browser\./]) {
+ assert.doesNotMatch(src, re, `lib/${file} uses ${re}`);
+ }
}
}
+ // Reading a rating presses nothing and types nothing.
+ assert.doesNotMatch(fs.readFileSync(path.join(DIR, 'lib', 'rating.js'), 'utf8'), /\.click\s*\(|dispatchEvent|\.value\s*=|\.focus\s*\(/, 'lib/rating.js only reads');
});
});
done();
done();
new file mode 100644
new file mode 100644
@@ -0,0 +1,125 @@
@@ -0,0 +1,125 @@
+'use strict';
+/**
+ * "Save its rating to OpenVibe Reviews" (browser/lib/rating.js, handoff.reviewUrl, platform.scripting.readRating,
+ * controller.saveRating):
+ * - the rating a page publishes for search engines (JSON-LD aggregateRating, an EmployerAggregateRating, @graph),
+ * and, without it, the stars' own labels ("4.5 star rating", "4.5 of 5 bubbles", "Rated 4.2 out of 5");
+ * - which pages are review sites (Google only on Maps; Yelp only on a business page);
+ * - the button opens openvibe.reviews/save with the page, its name and the numbers, for the person to check there,
+ * and nothing is read before the press; a page that cannot be read still opens the form, without numbers.
+ */
+const assert = require('assert');
+const rating = require('../browser/lib/rating');
+const host = require('../browser/lib/host');
+const handoffLib = require('../browser/lib/handoff');
+const { createApiClient } = require('../browser/lib/api');
+const { createPlatform } = require('../browser/lib/platform');
+const { createController } = require('../browser/lib/controller');
+const config = require('../browser/lib/config');
+const { check, done, startStub, fakeExt } = require('./helpers/harness');
+
+const GD = 'https://www.glassdoor.com/Overview/Working-at-Acme-EI_IE12345.11,15.htm';
+const YELP = 'https://www.yelp.com/biz/joes-pizza-new-york?osq=pizza&utm_source=x#reviews';
+
+(async () => {
+ const stub = await startStub();
+ const handoff = handoffLib.createHandoff(config);
+ const popup = ({ url, title = '', scriptResult = null }) => {
+ const ext = fakeExt({ url, title, scriptResult });
+ const platform = createPlatform(ext, { ...config, API_ORIGIN: stub.origin });
+ const api = createApiClient({ origin: stub.origin, fetch, getToken: platform.storage.getToken });
+ return { ext, controller: createController({ api, platform, host, handoff, isRental: handoffLib.isRental }) };
+ };
+
+ await check('numbers as pages print them', () => {
+ for (const [s, want] of [['4.5', 4.5], ['4,5', 4.5], ['1,234', 1234], ['1.234', 1234], ['12 345', 12345], [{ '@value': '3.9' }, 3.9], ['n/a', null], ['', null], [null, null]]) {
+ assert.strictEqual(rating.num(s), want, JSON.stringify(s));
+ }
+ });
+
+ await check('JSON-LD: a restaurant, an employer rating on its own, a @graph, and junk around them', () => {
+ const restaurant = JSON.stringify({ '@context': 'https://schema.org', '@type': 'Restaurant', name: "Joe's Pizza", address: { '@type': 'PostalAddress', addressLocality: 'New York', addressRegion: 'NY' },
+ aggregateRating: { '@type': 'AggregateRating', ratingValue: '4.5', reviewCount: '9,812' } });
+ assert.deepStrictEqual(rating.fromJsonLd(['{not json', '{"@type":"BreadcrumbList"}', restaurant]),
+ { name: "Joe's Pizza", value: 4.5, best: 5, count: 9812, place: 'New York, NY', type: 'Restaurant', how: 'structured' });
+ const employer = JSON.stringify({ '@context': 'https://schema.org', '@type': 'EmployerAggregateRating', itemReviewed: { '@type': 'Organization', name: 'Acme Corp' }, ratingValue: 3.9, bestRating: 5, ratingCount: 1234 });
+ assert.deepStrictEqual(rating.fromJsonLd([employer]), { name: 'Acme Corp', value: 3.9, best: 5, count: 1234, place: '', type: 'Organization', how: 'structured' });
+ const graph = JSON.stringify({ '@graph': [{ '@type': 'WebPage' }, { '@type': 'LocalBusiness', name: 'Acme Plumbing', aggregateRating: { ratingValue: '8.6', bestRating: '10', ratingCount: '40' } }] });
+ assert.strictEqual(rating.fromJsonLd([graph]).value, 8.6);
+ assert.strictEqual(rating.fromJsonLd([graph]).best, 10);
+ // A rating above its own scale is not a rating.
+ assert.strictEqual(rating.fromJsonLd([JSON.stringify({ name: 'X', aggregateRating: { ratingValue: '7', bestRating: '5' } })]), null);
+ });
+
+ await check('the stars\' labels, in page order, with the count that follows', () => {
+ assert.deepStrictEqual(rating.fromLabels(['Close', 'Menu', '4.5 star rating', '1,234 reviews']), { value: 4.5, best: 5, count: 1234, how: 'page' });
+ assert.deepStrictEqual(rating.fromLabels(['4.5 of 5 bubbles', '(2,001)']), { value: 4.5, best: 5, count: 2001, how: 'page' });
+ assert.deepStrictEqual(rating.fromLabels(['Rated 4.2 out of 5 stars']), { value: 4.2, best: 5, count: null, how: 'page' });
+ assert.deepStrictEqual(rating.fromLabels(['4.3 stars', 'Directions', '312 reviews']), { value: 4.3, best: 5, count: 312, how: 'page' });
+ assert.strictEqual(rating.fromLabels(['Share', 'Save', '5 photos']), null);
+ assert.strictEqual(rating.nameFrom('', "Joe's Pizza - New York, NY - Yelp"), "Joe's Pizza");
+ assert.strictEqual(rating.nameFrom('', 'Acme Corp Reviews | Glassdoor'), 'Acme Corp');
+ });
+
+ await check('read(): JSON-LD first, the labels otherwise, from a document', () => {
+ const doc = (scripts, labels, h1, title) => ({
+ title,
+ querySelector: (sel) => (sel === 'h1' && h1 ? { textContent: h1 } : null),
+ querySelectorAll: (sel) => (sel.startsWith('script') ? scripts.map((t) => ({ textContent: t })) : labels.map((l) => ({ getAttribute: (k) => (k === 'aria-label' ? l : null) }))),
+ });
+ assert.strictEqual(rating.read(doc([JSON.stringify({ '@type': 'Organization', name: 'Acme', aggregateRating: { ratingValue: 4.1, ratingCount: 10 } })], ['2 star rating'], 'Acme Corp', '')).value, 4.1);
+ assert.deepStrictEqual(rating.read(doc([], ['4 star rating', '88 reviews'], "Joe's Pizza", '')), { value: 4, best: 5, count: 88, how: 'page', name: "Joe's Pizza", place: '', type: '' });
+ assert.deepStrictEqual(rating.read(doc([], [], '', 'Acme | Trustpilot')), { name: 'Acme', value: null, best: 5, count: null, place: '', type: '', how: 'none' });
+ });
+
+ await check('review sites: which pages count', () => {
+ const yes = [GD, 'https://www.indeed.com/cmp/Acme/reviews', YELP, 'https://www.bbb.org/us/ny/new-york/profile/plumber/acme-0121-1', 'https://www.trustpilot.com/review/acme.com',
+ 'https://www.google.com/maps/place/Joe%27s+Pizza/@40.7,-73.9,17z', 'https://www.tripadvisor.com/Restaurant_Review-g60763-d1-Reviews-Joes_Pizza-New_York_City_New_York.html', 'https://www.glassdoor.co.uk/Reviews/Acme-Reviews-E1.htm'];
+ for (const u of yes) assert.ok(handoffLib.reviewSite(u), u);
+ const no = ['https://www.google.com/search?q=acme', 'https://www.yelp.com/search?find_desc=pizza', 'https://www.indeed.com/jobs?q=x', 'http://www.glassdoor.com/x', 'https://glassdoor.com.evil.example/x', 'https://notyelp.com/biz/x', 'https://acme.com/'];
+ for (const u of no) assert.strictEqual(handoffLib.reviewSite(u), null, u);
+ });
+
+ await check('the Reviews address: the page (cleaned), its name, the numbers, the kind from the site or the type', () => {
+ const u = new URL(handoff.reviewUrl(YELP, "Joe's Pizza - Yelp", { name: "Joe's Pizza", value: 4.5, best: 5, count: 9812, place: 'New York, NY', type: 'Restaurant' }));
+ assert.strictEqual(u.origin + u.pathname, 'https://openvibe.reviews/save');
+ assert.deepStrictEqual(Object.fromEntries(u.searchParams), { site: 'yelp', url: 'https://www.yelp.com/biz/joes-pizza-new-york?osq=pizza', name: "Joe's Pizza", kind: 'restaurant', place: 'New York, NY', rating: '4.5', best: '5', count: '9812', via: 'companion' });
+ const g = new URL(handoff.reviewUrl(GD, 'Working at Acme | Glassdoor', { name: 'Acme Corp', value: 3.9, best: 5, count: null, type: 'Organization' }));
+ assert.strictEqual(g.searchParams.get('kind'), 'employer', 'Glassdoor is about employers whatever the type says');
+ assert.strictEqual(g.searchParams.get('count'), null);
+ // Unread: the page and its name, no numbers.
+ const none = new URL(handoff.reviewUrl('https://www.trustpilot.com/review/acme.com', 'Acme Reviews | Read Customer Service Reviews of acme.com', null));
+ assert.deepStrictEqual(Object.fromEntries(none.searchParams), { site: 'trustpilot', url: 'https://www.trustpilot.com/review/acme.com', name: 'Acme Reviews', via: 'companion' });
+ assert.strictEqual(handoff.reviewUrl('https://acme.com/', 'Acme', { value: 4 }), null, 'not a review site');
+ assert.ok(handoff.isHandoff('https://openvibe.reviews/save?x=1'));
+ });
+
+ await check('the popup: offered on a review site only, reads the page on the press, opens Reviews', async () => {
+ const read = { name: "Joe's Pizza", value: 4.5, best: 5, count: 9812, place: 'New York, NY', type: 'Restaurant', how: 'structured' };
+ const p = popup({ url: YELP, title: "Joe's Pizza - Yelp", scriptResult: read });
+ await p.controller.open();
+ assert.strictEqual(p.controller.page().review, true);
+ assert.ok(!p.ext.log.some((l) => l[0] === 'scripting.executeScript'), 'nothing runs in the page before the press');
+ const r = await p.controller.saveRating();
+ assert.deepStrictEqual([r.ok, r.read], [true, true]);
+ const injected = p.ext.log.filter((l) => l[0] === 'scripting.executeScript').map((l) => l[1]);
+ assert.deepStrictEqual(injected[0], { target: { tabId: 1 }, files: ['lib/rating.js'] });
+ assert.deepStrictEqual(injected[1].target, { tabId: 1 }, 'the active tab, its top frame');
+ assert.strictEqual(p.ext.opened.length, 1);
+ assert.match(p.ext.opened[0], /^https:\/\/openvibe\.reviews\/save\?site=yelp&url=https%3A%2F%2Fwww\.yelp\.com%2Fbiz%2Fjoes-pizza-new-york%3Fosq%3Dpizza&name=Joe%27s\+Pizza&kind=restaurant&place=New\+York%2C\+NY&rating=4\.5&best=5&count=9812&via=companion$/);
+ assert.ok(!stub.requests.some((x) => /pizza|biz/i.test([x.url, x.body].join(' '))), 'beyond the hostname (the coupon lookup), the page goes only to Reviews, in the tab the person sees');
+
+ const unread = popup({ url: 'https://www.trustpilot.com/review/acme.com', title: 'Acme | Trustpilot', scriptResult: null });
+ await unread.controller.open();
+ const r2 = await unread.controller.saveRating();
+ assert.deepStrictEqual([r2.ok, r2.read], [true, false]);
+ assert.ok(!/rating=/.test(unread.ext.opened[0]), 'no numbers when the page could not be read');
+
+ const shop = popup({ url: 'https://shop.acme.com/cart', title: 'Cart' });
+ await shop.controller.open();
+ assert.strictEqual(shop.controller.page().review, false);
+ });
+
+ await stub.close();
+ done();
+})();