openvibers/openvibe.extensions Commits

OpenVibe Companion 0.6.0: Keep on my Rent board and Check the rent carry the price, city and bedrooms the listing states, from any site (#9)

OP OpenVibers committed 21 days ago · b3a7cb6d12 · 12 files, +378 −34

lib/listing.js, injected only on the press like lib/rating.js, reads the listing's structured data
(JSON-LD homes, RealEstateListing, offers and price specifications, a building around an apartment;
then microdata) and else its price tag (og:price, Craigslist's price beside its title). It returns the
price and currency, 'City, Region' (never the street) and the bedrooms. The board gets the price and
place; the fair check gets a monthly dollar rent, the bedrooms and the city. Rent's pages already take
those parameters, and the person checks them there before anything is saved.

Changes

ddiff --git a/README.md b/README.md
iindex ea5cd8d..d37e112 100644
---- a/README.md
++++ b/README.md
@@@ -35,11 +35,14 @@ One button in the browser for the OpenVibe sites that work with the page you are
- **Keep on my Rent board.** On a rental listing (Craigslist, Facebook Marketplace, Zillow,
Apartments.com, Zumper, Airbnb and the rest) or any page, it opens
[openvibe.rent/board](https://openvibe.rent/board) with the listing's address and title filled in,
-- so every listing from every site sits in one list with your notes and how far you got. On a rental
-- site this is the main button.
++ and the price and city the page states (`lib/listing.js` reads them when you press: the structured data
++ the site publishes for search engines, else its price tag), so every listing from every site sits in one
++ list with your notes and how far you got. You check them there before saving. On a rental site this is
++ the main button.
- **Check the rent.** On a rental listing it opens [openvibe.rent/fair](https://openvibe.rent/fair) with the listing's
-- address and title: Rent reads the monthly rent, the bedrooms and the city from them (and says which it read), and
-- compares the rent with the city's median for that size from the Census Bureau. Nothing is read from the page itself.
++ address and title, and the monthly rent in dollars, the bedrooms and the city the page states (read the same way;
++ a sale price or another currency is left out). Rent reads what is missing from the address and title (and says
++ which it read), and compares the rent with the city's median for that size from the Census Bureau.
- **Watch this page.** It opens a new watch on [OpenVibe.Watch](https://openvibe.watch) for the page,
which tells you (or runs your agent) when it changes.
- **Compare delivery apps.** On DoorDash, Uber Eats, Grubhub, Seamless, Caviar or Instacart it opens
@@@ -72,11 +75,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 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. |
++| **Permissions** | `activeTab`, `scripting` and `storage`. The host permission is `https://openvibe.coupons/*` only. `scripting` is used for four buttons: "Try these codes on this page" injects the packaged `lib/autoapply.js`, "Save its rating to OpenVibe Reviews" injects `lib/rating.js`, and "Keep on my Rent board" and "Check the rent" inject `lib/listing.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. 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. |
++| **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. "Keep on my Rent board" and "Check the rent" run `lib/listing.js`, which only reads: the price and its currency, the city and region (never the street) and the bedrooms, from the structured data, else the page's price tag. 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", "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. |
++| **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; for Rent, the price, city and bedrooms 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. |
@@@ -113,6 +116,7 @@ browser/
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/listing.js "Keep on my Rent board" / "Check the rent": reads a listing's price, city and bedrooms, injected only when you press
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 c381a05..1107756 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -5,11 +5,11 @@
"wave": "W18",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"adr": "OpenVibe.Contracts ADR-023 (first-party client surfaces)",
-- "version": "0.5.0",
++ "version": "0.6.0",
"surfaces": {
"browser": {
"name": "OpenVibe Companion",
-- "version": "0.5.0",
++ "version": "0.6.0",
"manifest": 3,
"targets": [
"chromium >= 111",
@@@ -47,7 +47,7 @@
"deployed": false,
"features": [
"OpenVibe Companion (Manifest V3): tries the coupon codes OpenVibe.Coupons knows on a checkout page",
-- "keeps a rental listing on the OpenVibe.Rent board, and checks its rent against the city median",
++ "keeps a rental listing from any site on the OpenVibe.Rent board with the price, city and bedrooms the page states (read only when pressed), and checks its rent against the city median",
"watches a page on OpenVibe.Watch, and compares delivery apps on OpenVibe.Food",
"saves a review site's stated rating to OpenVibe.Reviews (read only when pressed, only on those sites)",
"an optional cpx_ install token, never a Network session; only openvibe.coupons as a host permission",
@@@ -55,5 +55,5 @@
"kiosk companion (Firefox new-tab page and history bridge for Live's /kiosk), moved from OpenVibe.Live",
"hardware/: the Raspberry Pi streamer and controller scripts, moved from OpenVibe.Live"
],
-- "updated": "2026-10-22"
++ "updated": "2026-10-23"
}
ddiff --git a/browser/lib/controller.js b/browser/lib/controller.js
iindex b866501..b2aee36 100644
---- a/browser/lib/controller.js
++++ b/browser/lib/controller.js
@@@ -4,8 +4,8 @@
* const c = createController({ api, platform, host, handoff });
* const vm = await c.open(); // the codes for the active tab's shop
* c.page() // { handoff, rental }: which buttons to offer
-- * await c.keepOnBoard() // open the Rent board with this page (a new tab)
-- * await c.checkRent() // is this rent fair? on openvibe.rent/fair (a new tab)
++ * await c.keepOnBoard() // open the Rent board with this page and the price and city it states (lib/listing.js)
++ * await c.checkRent() // is this rent fair? on openvibe.rent/fair, with what the page states (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)
@@@ -51,6 +51,11 @@
const handoff = deps.handoff || null;
let tab = { id: null, url: null, title: '' };
++ async function readListing() {
++ if (!handoff || !host.hostnameOf(tab.url) || !platform.scripting || !platform.scripting.readListing) return null;
++ try { return await platform.scripting.readListing(tab.id); } catch (e) { return null; }
++ }
++
async function handOff(build) {
const target = handoff && build(tab.url, tab.title);
if (!target) return { ok: false, message: 'This page cannot be sent there.' };
@@@ -86,8 +91,19 @@
};
},
-- keepOnBoard() { return handOff(function (u, t) { return handoff.boardUrl(u, t); }); },
-- checkRent() { return handOff(function (u, t) { return handoff.fairUrl(u, t); }); },
++ /**
++ * Keep this page on the Rent board, or check its rent: the price, city and bedrooms the page states
++ * (lib/listing.js, injected on this press) go along for the person to check there. A page that cannot be
++ * read still opens, with its address and title.
++ */
++ async keepOnBoard() {
++ const listing = await readListing();
++ return handOff(function (u, t) { return handoff.boardUrl(u, t, listing); });
++ },
++ async checkRent() {
++ const listing = await readListing();
++ return handOff(function (u, t) { return handoff.fairUrl(u, t, listing); });
++ },
watchPage() { return handOff(function (u, t) { return handoff.watchUrl(u, t); }); },
compareDelivery() { return handOff(function (u, t) { return handoff.deliveryUrl(u, t); }); },
ddiff --git a/browser/lib/handoff.js b/browser/lib/handoff.js
iindex fe1104b..7ea9bba 100644
---- a/browser/lib/handoff.js
++++ b/browser/lib/handoff.js
@@@ -2,9 +2,11 @@
* Hand-offs: the pages the Companion opens on OpenVibe sites with the page you are on, when you
* press a button for it — never on its own.
*
-- * boardUrl(url, title) https://openvibe.rent/board?url=…&title=… keep a listing on your Rent board
-- * fairUrl(url, title) https://openvibe.rent/fair?url=…&title=… is this rent fair? (Rent reads the rent, size
-- * and city from the address and title, says what it read, and compares with the city's median)
++ * boardUrl(url, title, listing) https://openvibe.rent/board?url=…&title=…&price=…&place=… keep a listing on your
++ * Rent board, with the price and "City, Region" lib/listing.js read from the page when it could
++ * fairUrl(url, title, listing) https://openvibe.rent/fair?url=…&title=…&rent=…&beds=…&where=… is this rent fair?
++ * (what the page states, else Rent reads the rent, size and city from the address and title;
++ * it says what it read and compares with the city's median)
* watchUrl(url, title) https://openvibe.watch/watches/new?template=page… watch the page for changes
* 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
@@@ -145,8 +147,8 @@
const REVIEWS = config.REVIEWS_ORIGIN;
const origins = config.HANDOFF_ORIGINS;
return {
-- boardUrl: function (url, title) { return boardUrl(RENT, url, title); },
-- fairUrl: function (url, title) { return fairUrl(RENT, url, title); },
++ boardUrl: function (url, title, listing) { return boardUrl(RENT, url, title, listing); },
++ fairUrl: function (url, title, listing) { return fairUrl(RENT, url, title, listing); },
watchUrl: function (url, title) { return watchUrl(WATCH, url, title); },
deliveryUrl: function (url, title) {
if (!FOOD || !isDelivery(url)) return null;
@@@ -158,21 +160,35 @@
};
}
-- function boardUrl(RENT, url, title) {
++ /** A number lib/listing.js read, as the address carries it ("1850", "1850.5"); '' when there is none. */
++ function amount(v) {
++ return typeof v === 'number' && Number.isFinite(v) && v > 0 && v < 1e8 ? String(Math.round(v * 100) / 100) : '';
++ }
++
++ function boardUrl(RENT, url, title, listing) {
const page = cleanUrl(url);
if (!page) return null;
++ const got = listing || {};
const q = new URLSearchParams({ url: page });
const t = cleanTitle(title, 200);
if (t) q.set('title', t);
++ if (amount(got.price)) q.set('price', amount(got.price));
++ if (got.place) q.set('place', cleanTitle(got.place, 120));
return RENT + '/board?' + q.toString();
}
-- function fairUrl(RENT, url, title) {
++ function fairUrl(RENT, url, title, listing) {
const page = cleanUrl(url);
if (!page) return null;
++ const got = listing || {};
const q = new URLSearchParams({ url: page });
const t = cleanTitle(title, 200);
if (t) q.set('title', t);
++ // The fair check compares a monthly rent in dollars with the Census median: a sale price or another
++ // currency is left for the person to type.
++ if (amount(got.price) && got.price <= 50000 && (!got.currency || got.currency === 'USD')) q.set('rent', String(Math.round(got.price)));
++ if (Number.isInteger(got.beds) && got.beds >= 0 && got.beds <= 20) q.set('beds', String(got.beds));
++ if (got.place) q.set('where', cleanTitle(got.place, 120));
return RENT + '/fair?' + q.toString();
}
ddiff --git a/browser/lib/listing.js b/browser/lib/listing.js
nnew file mode 100644
iindex 0000000..ffd1b29
---- /dev/null
++++ b/browser/lib/listing.js
@@@ -0,0 +1,157 @@
++/*
++ * Read a listing's price, place and size from its page: the OpenVibe Companion's "Keep on my Rent board" and
++ * "Check the rent".
++ *
++ * Injected into the active tab only when the person presses one of those buttons (activeTab + scripting, as
++ * lib/rating.js is), on any site: a listing is kept from wherever the person found it. 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) → { price, currency, place, beds } | null
++ * price the asking price as a number (1850 for "$1,850/mo"), or null
++ * currency its three-letter code when the page says (USD, CAD, GBP…), or ''
++ * place "City, Region" from the listing's address, never the street, or ''
++ * beds bedrooms (0 for a studio) when the page states them, or null
++ *
++ * Where it looks: the structured data the site publishes for search engines (JSON-LD, then microdata), then the
++ * price tags a listing page shows (og:price / product:price meta tags, Craigslist's price beside its title). Never
++ * the description, the photos, the contact details or anything about the person. The popup opens the Rent board or
++ * the fair check with these values in the address; the person checks them there before anything is saved.
++ */
++(function (root, factory) {
++ const api = factory();
++ if (typeof module === 'object' && module.exports) module.exports = api;
++ else root.OVListing = api;
++})(typeof self !== 'undefined' ? self : this, function () {
++ 'use strict';
++
++ // What a listing is about (schema.org): a home, a room, a place to stay, and the offer or listing that sells it.
++ const HOME = /^(Apartment|House|SingleFamilyResidence|Residence|ApartmentComplex|GatedResidenceCommunity|Accommodation|Room|HotelRoom|Suite|VacationRental|LodgingBusiness|Place)$/;
++ const OFFER = /^(RealEstateListing|Product|Offer|AggregateOffer)$/;
++ const MAX_PRICE = 100000000;
++
++ function text(v, max) {
++ return String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max || 200);
++ }
++
++ /**
++ * A price as pages print it: "$1,850/mo" 1850, "1.850,50 €" 1850.5, "2300.00" 2300, 1850 1850. Groups of three
++ * after a comma or dot are thousands; one or two digits after the last one are cents.
++ */
++ function price(v) {
++ if (v == null) return null;
++ if (typeof v === 'number') return Number.isFinite(v) && v > 0 && v < MAX_PRICE ? v : null;
++ const s = String(typeof v === 'object' && v['@value'] != null ? v['@value'] : v).replace(/\s/g, '');
++ const m = /(\d{1,3}(?:[,.]\d{3})+|\d+)(?:[.,](\d{1,2}))?(?!\d)/.exec(s);
++ if (!m) return null;
++ const n = Number(m[1].replace(/[,.]/g, '') + (m[2] ? '.' + m[2] : ''));
++ return Number.isFinite(n) && n > 0 && n < MAX_PRICE ? n : null;
++ }
++
++ function currency(v) {
++ const c = text(v, 8).toUpperCase();
++ return /^[A-Z]{3}$/.test(c) ? c : '';
++ }
++
++ function typesOf(o) {
++ const t = o && o['@type'];
++ return (Array.isArray(t) ? t : [t]).map(function (x) { return text(x, 60).replace(/^https?:\/\/schema\.org\//, ''); }).filter(Boolean);
++ }
++
++ function first(v) { return Array.isArray(v) ? v[0] : v; }
++
++ /** "City, Region" from a PostalAddress, or from an address line's last two parts ("…, Oakland, CA 94607"). */
++ function placeOf(o) {
++ const a = first(o && o.address);
++ if (!a) return '';
++ if (typeof a === 'string') {
++ const parts = a.split(',').map(function (x) { return text(x, 60); }).filter(Boolean);
++ if (parts.length < 2) return '';
++ const region = parts[parts.length - 1].replace(/\b\d{4,6}(?:-\d{4})?\b/g, '').trim();
++ const city = parts[parts.length - 2];
++ return city && region && !/\d/.test(city) ? city + ', ' + region : '';
++ }
++ if (typeof a !== 'object') return '';
++ const city = text(a.addressLocality, 60);
++ const country = typeof a.addressCountry === 'object' && a.addressCountry ? a.addressCountry.name : a.addressCountry;
++ const region = text(a.addressRegion, 40) || text(country, 40);
++ return city ? [city, region].filter(Boolean).join(', ') : '';
++ }
++
++ function bedsOf(o) {
++ const b = first(o && o.numberOfBedrooms);
++ const n = b != null && typeof b === 'object' ? Number(b.value) : Number(b);
++ return b != null && b !== '' && Number.isInteger(n) && n >= 0 && n <= 20 ? n : null;
++ }
++
++ /** The price an offer, a listing or a home states: its own, its price specification's, else its first offer's. */
++ function priceOf(o, depth) {
++ if (!o || typeof o !== 'object' || (depth || 0) > 3) return null;
++ const spec = first(o.priceSpecification);
++ const p = price(o.price) || price(o.lowPrice) || (spec && typeof spec === 'object' ? price(spec.price) || price(spec.minPrice) : null);
++ if (p) return { price: p, currency: currency(o.priceCurrency) || (spec && typeof spec === 'object' ? currency(spec.priceCurrency) : '') };
++ return o.offers ? priceOf(first(o.offers), (depth || 0) + 1) : null;
++ }
++
++ /** The listing in a page's JSON-LD blocks (their text): its price, place and bedrooms, each from the first object that states it. */
++ function fromJsonLd(blocks) {
++ const out = { price: null, currency: '', place: '', beds: null };
++ const seen = [];
++ const visit = function (o, depth) {
++ if (!o || typeof o !== 'object' || depth > 6 || seen.length > 2000) return;
++ seen.push(o);
++ if (Array.isArray(o)) { o.forEach(function (x) { visit(x, depth + 1); }); return; }
++ const types = typesOf(o);
++ const home = types.some(function (t) { return HOME.test(t); });
++ if (home || types.some(function (t) { return OFFER.test(t); })) {
++ if (out.price == null) { const p = priceOf(o); if (p) { out.price = p.price; out.currency = p.currency; } }
++ // A business's own address (the agent's office) is not where the home is.
++ if (!out.place && home) out.place = placeOf(o);
++ if (out.beds == null) out.beds = bedsOf(o);
++ }
++ ['@graph', 'mainEntity', 'itemOffered', 'offers', 'about', 'containedInPlace'].forEach(function (k) { if (o[k]) visit(o[k], depth + 1); });
++ };
++ for (const b of blocks || []) {
++ let data;
++ try { data = JSON.parse(String(b || '')); } catch (e) { continue; }
++ visit(data, 0);
++ }
++ return out.price != null || out.place || out.beds != null ? out : null;
++ }
++
++ /** An attribute's value or an element's text, for the first element `sel` finds. */
++ function valueAt(doc, sel, attr) {
++ const el = doc.querySelector(sel);
++ if (!el) return '';
++ return text((attr && el.getAttribute(attr)) || el.getAttribute('content') || el.textContent, 120);
++ }
++
++ /** Microdata and the price tags a listing page shows, for what the JSON-LD left out. */
++ function fromPage(doc) {
++ const out = { price: null, currency: '', place: '', beds: null };
++ out.price = price(valueAt(doc, '[itemprop="price"]')) || price(valueAt(doc, 'meta[property="og:price:amount"]', 'content'))
++ || price(valueAt(doc, 'meta[property="product:price:amount"]', 'content')) || price(valueAt(doc, '.postingtitletext .price'));
++ out.currency = currency(valueAt(doc, '[itemprop="priceCurrency"]')) || currency(valueAt(doc, 'meta[property="og:price:currency"]', 'content'))
++ || currency(valueAt(doc, 'meta[property="product:price:currency"]', 'content'));
++ const city = valueAt(doc, '[itemprop="addressLocality"]');
++ if (city && !/\d/.test(city)) out.place = [city, valueAt(doc, '[itemprop="addressRegion"]')].filter(Boolean).join(', ').slice(0, 120);
++ const beds = valueAt(doc, '[itemprop="numberOfBedrooms"]');
++ out.beds = beds ? bedsOf({ numberOfBedrooms: beds }) : null;
++ return out;
++ }
++
++ 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 ld = fromJsonLd(blocks) || { price: null, currency: '', place: '', beds: null };
++ const page = fromPage(doc);
++ const out = {
++ price: ld.price != null ? ld.price : page.price,
++ currency: ld.price != null ? ld.currency : page.currency,
++ place: ld.place || page.place,
++ beds: ld.beds != null ? ld.beds : page.beds,
++ };
++ return out.price != null || out.place || out.beds != null ? out : null;
++ }
++
++ return { read, fromJsonLd, fromPage, price, placeOf };
++});
ddiff --git a/browser/lib/platform.js b/browser/lib/platform.js
iindex 044b008..46c11ba 100644
---- a/browser/lib/platform.js
++++ b/browser/lib/platform.js
@@@ -15,6 +15,8 @@
* 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
++ * scripting.readListing(tabId) when the person presses "Keep on my Rent board" or "Check the rent": inject
++ * lib/listing.js the same way and return the price, city and bedrooms the page states
*
* Nothing else: no cookies, no history, and the page is touched only by that one button.
*/
@@@ -62,6 +64,12 @@
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;
},
++ async readListing(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/listing.js'] }]);
++ const out = await call(ext.scripting.executeScript, ext.scripting, [{ target: { tabId: tabId }, func: function () { return self.OVListing.read(document); } }]);
++ return out && out[0] ? out[0].result : null;
++ },
},
storage: {
async getToken() {
ddiff --git a/browser/manifest.json b/browser/manifest.json
iindex 011d95f..541e2e2 100644
---- a/browser/manifest.json
++++ b/browser/manifest.json
@@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "OpenVibe Companion",
"short_name": "OpenVibe",
-- "version": "0.5.0",
++ "version": "0.6.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",
ddiff --git a/package.json b/package.json
iindex 9b69a7b..0ab9552 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-extensions",
-- "version": "0.5.0",
++ "version": "0.6.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/store/listing.md b/store/listing.md
iindex 4904703..2109828 100644
---- a/store/listing.md
++++ b/store/listing.md
@@@ -37,7 +37,7 @@ AMO "summary" (250 at most): the same sentence.
>
> **Save a review site's rating.** On Glassdoor, Indeed, Yelp, the BBB, Trustpilot, Google Maps or Tripadvisor, "Save its rating to OpenVibe Reviews" reads the rating the page states and opens openvibe.reviews to check and save it.
>
--> **Keep a listing, watch a page.** "Keep on my Rent board" and "Check the rent" open OpenVibe Rent with the listing's address; "Watch this page" opens OpenVibe Watch to tell you when the page changes.
++> **Keep a listing, watch a page.** "Keep on my Rent board" and "Check the rent" open OpenVibe Rent with the listing's address and the price, city and bedrooms it states, from any site; "Watch this page" opens OpenVibe Watch to tell you when the page changes.
>
> **What it can see.** The Companion asks for no access to your sites until you open it on a tab (the browser's "active tab" permission). It has no background worker, no content scripts, and no access to your history, cookies or other tabs. Every hand-off opens a tab you can see, and nothing is saved until you save it there. The only thing it stores is the optional install token, on this device.
>
ddiff --git a/store/privacy.md b/store/privacy.md
iindex 6c35fdb..d857e99 100644
---- a/store/privacy.md
++++ b/store/privacy.md
@@@ -13,7 +13,7 @@ reads, sends or stores, change this file, the README's "What it can see" table a
| Permission | Justification |
|---|---|
| `activeTab` | When the person opens the popup, it reads the active tab's address and title, to look up coupon codes for that site by its hostname and to fill the hand-off buttons. No access to any tab before that click, and none to other tabs. |
--| `scripting` | Two buttons inject a script packaged in the extension into the active tab's top frame, only after the press: "Try these codes on this page" runs `lib/autoapply.js` (finds the coupon box, types each code, reads whether the order total changed) and "Save its rating to OpenVibe Reviews" runs `lib/rating.js` (reads the rating the page states). Neither makes a request or reads cookies or storage. |
++| `scripting` | Four buttons inject a script packaged in the extension into the active tab's top frame, only after the press: "Try these codes on this page" runs `lib/autoapply.js` (finds the coupon box, types each code, reads whether the order total changed), "Save its rating to OpenVibe Reviews" runs `lib/rating.js` (reads the rating the page states), and "Keep on my Rent board" and "Check the rent" run `lib/listing.js` (reads the price, city and bedrooms a listing states). None makes a request or reads cookies or storage. |
| `storage` | Keeps the optional `cpx_` install token the person pastes on the settings page, in `storage.local` (never synced). Nothing else is stored. |
| Host permission `https://openvibe.coupons/*` | The one API the extension calls: look up a merchant by hostname, list its codes, and report whether a code worked. |
| Remote code | **No.** All JavaScript is in the package; the CSP is `script-src 'self'` with no `eval`. |
@@@ -25,11 +25,11 @@ Tick these data types, because each leaves the device in some case:
| Type | Ticked | What, when, where |
|---|---|---|
| Web history | **Yes** | The active tab's **hostname** goes to openvibe.coupons when the popup opens on a site. On a hand-off press, the page's address (credentials, fragment and tracking parameters removed) and title go to openvibe.rent, openvibe.watch, openvibe.food or openvibe.reviews in the address of a tab the person sees. |
--| Website content | **Yes** | On "Save its rating to OpenVibe Reviews": the business name, rating, scale, count of ratings and city the page states, in the address of the openvibe.reviews tab. On "Compare delivery apps": the restaurant or store name taken from the page title, to openvibe.food. |
++| Website content | **Yes** | On "Save its rating to OpenVibe Reviews": the business name, rating, scale, count of ratings and city the page states, in the address of the openvibe.reviews tab. On "Compare delivery apps": the restaurant or store name taken from the page title, to openvibe.food. On "Keep on my Rent board" and "Check the rent": the price, its currency, the city and region (never the street) and the bedrooms the listing states, in the address of the openvibe.rent tab. |
| Authentication information | **Yes** | Only if the person connects the extension: the `cpx_` install token they pasted is sent to openvibe.coupons as `Authorization: Bearer` on its API calls. It is issued by openvibe.coupons and sent nowhere else. |
| User activity | No | No clicks, keystrokes or scrolls are recorded. A report sends a code id, `worked`/`failed` and an optional reason the person chose, which is the person's own feedback. |
| Financial and payment information | No | `lib/autoapply.js` reads the order total in the page to tell which codes lowered it; the total stays in the popup and is never sent. |
--| Personally identifiable information, health, personal communications, location | No | Not read. (The city sent with a rating is the business's, from the page's structured data.) |
++| Personally identifiable information, health, personal communications, location | No | Not read. (The city sent with a rating is the business's, and with a listing the home's, from the page; never the person's.) |
Certify all three: the data is not sold to third parties; it is not used or transferred for
purposes unrelated to the single purpose; it is not used or transferred to determine
ddiff --git a/test/listing.test.js b/test/listing.test.js
nnew file mode 100644
iindex 0000000..276c792
---- /dev/null
++++ b/test/listing.test.js
@@@ -0,0 +1,141 @@
++'use strict';
++/**
++ * "Keep on my Rent board" and "Check the rent" with what the listing states (browser/lib/listing.js, handoff.boardUrl and
++ * fairUrl, platform.scripting.readListing, controller.keepOnBoard and checkRent):
++ * - the price, "City, Region" and bedrooms a page publishes for search engines (JSON-LD: a home, a RealEstateListing
++ * and its mainEntity, an offer's price specification, a @graph beside an agency's own address, a building around
++ * an apartment), then microdata and price tags (og:price, Craigslist's price beside its title);
++ * - never the street: an address line gives its city and region only;
++ * - the board gets the price and place, the fair check a monthly dollar rent, the bedrooms and the city;
++ * - nothing is read before the press, and a page that cannot be read still opens with its address and title.
++ */
++const assert = require('assert');
++const listing = require('../browser/lib/listing');
++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 CL = 'https://saltlakecity.craigslist.org/apa/d/salt-lake-city-sunny-2br/7712345678.html?utm_source=x#photos';
++const SHOP = 'https://www.rentfaster.ca/ab/calgary/rentals/apartment/2-bedrooms/123456';
++
++/** A document as listing.read() sees it: JSON-LD blocks, and elements by selector ({ text, content }). */
++function doc(scripts, elements = {}) {
++ const el = (v) => (v ? { textContent: v.text || '', getAttribute: (k) => (k === 'content' ? v.content || null : null) } : null);
++ return {
++ querySelector: (sel) => el(elements[sel]),
++ querySelectorAll: (sel) => (sel.startsWith('script') ? scripts.map((t) => ({ textContent: typeof t === 'string' ? t : JSON.stringify(t) })) : []),
++ };
++}
++
++(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('prices as pages print them', () => {
++ for (const [s, want] of [['$1,850', 1850], ['$1,850/mo', 1850], ['1850', 1850], [1850, 1850], ['2300.00', 2300], ['1,850.50', 1850.5], ['1.850,50 €', 1850.5],
++ ['£1.200 pcm', 1200], ['CA$ 2 150', 2150], [{ '@value': '975' }, 975], ['Contact for price', null], ['', null], [null, null], [0, null], [-5, null], ['$0', null]]) {
++ assert.strictEqual(listing.price(s), want, JSON.stringify(s));
++ }
++ });
++
++ await check('JSON-LD: a home, a RealEstateListing around it, a price specification, a @graph with an agency, a building', () => {
++ assert.deepStrictEqual(listing.fromJsonLd([JSON.stringify({ '@context': 'https://schema.org', '@type': 'Apartment', name: 'Sunny 2BR', numberOfBedrooms: 2,
++ address: { '@type': 'PostalAddress', streetAddress: '123 Main St', addressLocality: 'Salt Lake City', addressRegion: 'UT', postalCode: '84101' } })]),
++ { price: null, currency: '', place: 'Salt Lake City, UT', beds: 2 }, 'the street stays on the page');
++ assert.deepStrictEqual(listing.fromJsonLd([JSON.stringify({ '@type': 'RealEstateListing', offers: { '@type': 'Offer', price: '2300', priceCurrency: 'usd' },
++ mainEntity: { '@type': 'SingleFamilyResidence', numberOfBedrooms: { '@type': 'QuantitativeValue', value: 3 }, address: { addressLocality: 'Austin', addressRegion: 'TX' } } })]),
++ { price: 2300, currency: 'USD', place: 'Austin, TX', beds: 3 });
++ assert.deepStrictEqual(listing.fromJsonLd(['not json', JSON.stringify({ '@graph': [
++ { '@type': 'RealEstateAgent', name: 'Agency', address: { addressLocality: 'Denver', addressRegion: 'CO' } },
++ { '@type': ['Product', 'Accommodation'], name: '2 bed', offers: [{ priceSpecification: { '@type': 'UnitPriceSpecification', price: 1450, priceCurrency: 'CAD', unitText: 'MONTH' } }],
++ address: { addressLocality: 'Toronto', addressCountry: { name: 'Canada' } } },
++ ] })]), { price: 1450, currency: 'CAD', place: 'Toronto, Canada', beds: null }, "the agency's office is not where the home is");
++ assert.deepStrictEqual(listing.fromJsonLd([JSON.stringify({ '@type': 'Apartment', numberOfBedrooms: 0,
++ containedInPlace: { '@type': 'ApartmentComplex', name: 'The Elms', address: { addressLocality: 'Portland', addressRegion: 'OR' }, offers: { '@type': 'AggregateOffer', lowPrice: '1,295' } } })]),
++ { price: 1295, currency: '', place: 'Portland, OR', beds: 0 }, 'a studio, in a building that states the place and the price');
++ assert.strictEqual(listing.fromJsonLd([JSON.stringify({ '@type': 'Organization', name: 'Acme', address: { addressLocality: 'Boston' } }), JSON.stringify({ '@type': 'BreadcrumbList' })]), null);
++ assert.strictEqual(listing.fromJsonLd(['{', '']), null);
++ });
++
++ await check('an address line gives its city and region, never the street', () => {
++ assert.strictEqual(listing.placeOf({ address: '123 Main St, Oakland, CA 94607' }), 'Oakland, CA');
++ assert.strictEqual(listing.placeOf({ address: 'Oakland, CA' }), 'Oakland, CA');
++ assert.strictEqual(listing.placeOf({ address: '123 Main St' }), '');
++ assert.strictEqual(listing.placeOf({ address: 'Unit 4, 55 Elm Rd' }), '', 'a street in the city spot gives nothing');
++ assert.strictEqual(listing.placeOf({ address: { streetAddress: '9 Elm' } }), '');
++ });
++
++ await check('read(): JSON-LD first; microdata and price tags fill what it left out; nothing found is null', () => {
++ const craigslist = doc([{ '@type': 'Apartment', numberOfBedrooms: 2, address: { addressLocality: 'Salt Lake City', addressRegion: 'UT' } }], { '.postingtitletext .price': { text: '$1,850' } });
++ assert.deepStrictEqual(listing.read(craigslist), { price: 1850, currency: '', place: 'Salt Lake City, UT', beds: 2 });
++ const tags = doc([], { 'meta[property="og:price:amount"]': { content: '2150.00' }, 'meta[property="og:price:currency"]': { content: 'CAD' } });
++ assert.deepStrictEqual(listing.read(tags), { price: 2150, currency: 'CAD', place: '', beds: null });
++ const micro = doc([], { '[itemprop="price"]': { content: '1395' }, '[itemprop="addressLocality"]': { text: ' Madison ' }, '[itemprop="addressRegion"]': { text: 'WI' }, '[itemprop="numberOfBedrooms"]': { text: '1' } });
++ assert.deepStrictEqual(listing.read(micro), { price: 1395, currency: '', place: 'Madison, WI', beds: 1 });
++ const ldWins = doc([{ '@type': 'Offer', price: 1700, priceCurrency: 'USD' }], { '.postingtitletext .price': { text: '$9' } });
++ assert.strictEqual(listing.read(ldWins).price, 1700, "the structured price before the page's");
++ assert.strictEqual(listing.read(doc([{ '@type': 'WebSite', name: 'x' }], {})), null);
++ });
++
++ await check('the board gets the price and the place; the fair check a monthly dollar rent, the bedrooms and the city', () => {
++ const read = { price: 1850, currency: '', place: 'Salt Lake City, UT', beds: 2 };
++ const b = new URL(handoff.boardUrl(CL, 'Sunny 2BR - apts/housing for rent', read));
++ assert.deepStrictEqual(Object.fromEntries(b.searchParams), { url: 'https://saltlakecity.craigslist.org/apa/d/salt-lake-city-sunny-2br/7712345678.html', title: 'Sunny 2BR - apts/housing for rent', price: '1850', place: 'Salt Lake City, UT' });
++ const f = new URL(handoff.fairUrl(CL, 'Sunny 2BR', read));
++ assert.deepStrictEqual(Object.fromEntries(f.searchParams), { url: b.searchParams.get('url'), title: 'Sunny 2BR', rent: '1850', beds: '2', where: 'Salt Lake City, UT' });
++ // A sale price or another currency is not a US monthly rent: the board keeps it, the fair check leaves it out.
++ assert.strictEqual(new URL(handoff.fairUrl(CL, 'x', { price: 450000, currency: 'USD', place: '', beds: 3 })).searchParams.get('rent'), null);
++ const cad = { price: 2150.5, currency: 'CAD', place: 'Calgary, AB', beds: null };
++ assert.strictEqual(new URL(handoff.fairUrl(SHOP, 'x', cad)).searchParams.get('rent'), null);
++ assert.strictEqual(new URL(handoff.boardUrl(SHOP, 'x', cad)).searchParams.get('price'), '2150.5');
++ // Unread: the address and title, as before.
++ assert.deepStrictEqual(Object.fromEntries(new URL(handoff.boardUrl(CL, 'Sunny', null)).searchParams), { url: b.searchParams.get('url'), title: 'Sunny' });
++ assert.deepStrictEqual(Object.fromEntries(new URL(handoff.fairUrl(CL, 'Sunny', { price: NaN, place: '', beds: 1.5 })).searchParams), { url: b.searchParams.get('url'), title: 'Sunny' });
++ });
++
++ await check('the popup: reads the page on the press only, opens the board and the fair check with what it read', async () => {
++ const read = { price: 1850, currency: '', place: 'Salt Lake City, UT', beds: 2 };
++ const p = popup({ url: CL, title: 'Sunny 2BR - apts/housing for rent - craigslist', scriptResult: read });
++ await p.controller.open();
++ assert.strictEqual(p.controller.page().rental, true);
++ assert.ok(!p.ext.log.some((l) => l[0] === 'scripting.executeScript'), 'nothing runs in the page before the press');
++ assert.deepStrictEqual(await p.controller.keepOnBoard(), { ok: 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/listing.js'] });
++ assert.deepStrictEqual(injected[1].target, { tabId: 1 }, 'the active tab, its top frame');
++ assert.match(p.ext.opened[0], /^https:\/\/openvibe\.rent\/board\?url=[^&]+&title=[^&]+&price=1850&place=Salt\+Lake\+City%2C\+UT$/);
++ assert.deepStrictEqual(await p.controller.checkRent(), { ok: true });
++ assert.match(p.ext.opened[1], /^https:\/\/openvibe\.rent\/fair\?url=[^&]+&title=[^&]+&rent=1850&beds=2&where=Salt\+Lake\+City%2C\+UT$/);
++ assert.ok(!stub.requests.some((x) => /\/apa\/|1850|Salt( |\+|%20)Lake/.test([x.url, x.body].join(' '))), 'beyond the hostname (the coupon lookup), the page goes only to Rent, in the tab the person sees');
++
++ // Any site: a page that is not a known rental site is read the same way when the person keeps it.
++ const any = popup({ url: 'https://elmstreetrentals.com/units/4b', title: 'Unit 4B', scriptResult: { price: 1200, currency: 'USD', place: 'Dayton, OH', beds: 1 } });
++ await any.controller.open();
++ assert.strictEqual(any.controller.page().rental, false);
++ await any.controller.keepOnBoard();
++ assert.match(any.ext.opened[0], /&price=1200&place=Dayton%2C\+OH$/);
++
++ const unread = popup({ url: CL, title: 'Sunny 2BR', scriptResult: null });
++ await unread.controller.open();
++ assert.deepStrictEqual(await unread.controller.keepOnBoard(), { ok: true });
++ assert.ok(!/price=|place=/.test(unread.ext.opened[0]), 'an unread page opens with its address and title only');
++
++ const browserPage = popup({ url: 'chrome://extensions', scriptResult: read });
++ await browserPage.controller.open();
++ assert.strictEqual((await browserPage.controller.keepOnBoard()).ok, false);
++ assert.ok(!browserPage.ext.log.some((l) => l[0] === 'scripting.executeScript'), 'nothing is injected where nothing can be kept');
++ });
++
++ await stub.close();
++ done();
++})();
ddiff --git a/test/manifest.test.js b/test/manifest.test.js
iindex 99ee842..ce002a7 100644
---- a/test/manifest.test.js
++++ b/test/manifest.test.js
@@@ -83,22 +83,24 @@ 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" and "Save its rating to OpenVibe Reviews": platform.js
-- // injects the packaged file for that button and calls it.
++ // The page is touched only by "Try these codes on this page", "Save its rating to OpenVibe Reviews" and the two Rent
++ // buttons ("Keep on my Rent board", "Check the rent"): 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'", "'lib/rating.js'"], 'the two files it injects');
++ assert.deepStrictEqual([...plat.matchAll(/files:\s*\[([^\]]*)\]/g)].map((m) => m[1].trim()), ["'lib/autoapply.js'", "'lib/rating.js'", "'lib/listing.js'"], 'the three 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 page makes no request and reads no stored data.
-- for (const file of ['autoapply.js', 'rating.js']) {
++ for (const file of ['autoapply.js', 'rating.js', 'listing.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');
++ // Reading a rating or a listing presses nothing and types nothing.
++ for (const file of ['rating.js', 'listing.js']) {
++ assert.doesNotMatch(fs.readFileSync(path.join(DIR, 'lib', file), 'utf8'), /\.click\s*\(|dispatchEvent|\.value\s*=|\.focus\s*\(/, `lib/${file} only reads`);
++ }
});
done();