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)

OP OpenVibers committed 27 days ago · dfe51a26a3 · 16 files, +412 −29

Changes

ddiff --git a/README.md b/README.md
iindex faa4326..4436ee5 100644
---- a/README.md
++++ b/README.md
@@@ -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 page
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
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
@@@ -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 only
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/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/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/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
ddiff --git a/STATUS.json b/STATUS.json
iindex 4137230..6791d7f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)"
]
}
},
ddiff --git a/browser/lib/config.js b/browser/lib/config.js
iindex d6ca8c3..b3cc495 100644
---- a/browser/lib/config.js
++++ b/browser/lib/config.js
@@@ -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',
ddiff --git a/browser/lib/controller.js b/browser/lib/controller.js
iindex 31f1b8d..b866501 100644
---- a/browser/lib/controller.js
++++ b/browser/lib/controller.js
@@@ -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.
ddiff --git a/browser/lib/handoff.js b/browser/lib/handoff.js
iindex b02caf0..fe1104b 100644
---- a/browser/lib/handoff.js
++++ b/browser/lib/handoff.js
@@@ -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 };
});
ddiff --git a/browser/lib/platform.js b/browser/lib/platform.js
iindex 9fdb139..044b008 100644
---- a/browser/lib/platform.js
++++ b/browser/lib/platform.js
@@@ -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() {
ddiff --git a/browser/lib/rating.js b/browser/lib/rating.js
nnew file mode 100644
iindex 0000000..35343a9
---- /dev/null
++++ b/browser/lib/rating.js
@@@ -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 };
++});
ddiff --git a/browser/manifest.json b/browser/manifest.json
iindex ea56116..4c269ce 100644
---- a/browser/manifest.json
++++ b/browser/manifest.json
@@@ -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": {
ddiff --git a/browser/popup.html b/browser/popup.html
iindex 4f89c8a..2d6d40f 100644
---- a/browser/popup.html
++++ b/browser/popup.html
@@@ -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>
ddiff --git a/browser/popup.js b/browser/popup.js
iindex 4d6732e..cc033ec 100644
---- a/browser/popup.js
++++ b/browser/popup.js
@@@ -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;
}
ddiff --git a/package.json b/package.json
iindex 1e38622..7f00628 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/scripts/audit.js b/scripts/audit.js
iindex aad5046..84004ae 100644
---- a/scripts/audit.js
++++ b/scripts/audit.js
@@@ -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,
ddiff --git a/test/autoapply.test.js b/test/autoapply.test.js
iindex 4602b43..fe540a6 100644
---- a/test/autoapply.test.js
++++ b/test/autoapply.test.js
@@@ -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']);
ddiff --git a/test/handoff.test.js b/test/handoff.test.js
iindex 90fc011..b281328 100644
---- a/test/handoff.test.js
++++ b/test/handoff.test.js
@@@ -61,7 +61,7 @@ const CLEAN = 'https://saltlakecity.craigslist.org/apa/d/sunny-2br/7790000001.ht
await 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.ht
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' });
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.ht
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' });
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();
ddiff --git a/test/manifest.test.js b/test/manifest.test.js
iindex 618eb1a..99ee842 100644
---- a/test/manifest.test.js
++++ b/test/manifest.test.js
@@@ -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();
ddiff --git a/test/rating.test.js b/test/rating.test.js
nnew file mode 100644
iindex 0000000..5f87c00
---- /dev/null
++++ b/test/rating.test.js
@@@ -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();
++})();