openvibers/openvibe.reviews Commits
Reviews: every review site, one search (#65)
Changes
@@@ -16,6 +16,17 @@ only from the signals that exist. It **never fabricates a rating and never preseuser reviews**: summaries are text written or approved by a person, every point cites the signalsit rests on, and nothing a summary says can become a number.++## Every review site, one search++++The owner's April ask was review aggregation across Glassdoor, Yelp, BBB, Reddit and the rest, "subject to legal and++technical feasibility". Their terms forbid copying their reviews, so `/everywhere`++([server/reviews/everywhere.js](server/reviews/everywhere.js)) does what they allow: one name, its kind (a local++business, an employer, a landlord, a company or online shop, a hosting provider, a product, software) and, for local++places, where it is, open each site's own search in new tabs: Google Maps, Yelp, Tripadvisor, BBB, Trustpilot,++Glassdoor, Indeed, Blind, LowEndTalk, RTINGS, YouTube reviews, Amazon, G2, Capterra, Product Hunt and Reddit. The URL++forms were checked in a real browser on 2026-10-15 (WebHostingTalk, ApartmentRatings and Rate My Landlord have no++search address that works). Reviews fetches and stores nothing from them; a result page is `noindex`.++## OwnsThe nine authority tables of §15.13, in Reviews' own PostgreSQL database (`ov_reviews` on the host's data role, ADR-035; schema in [migrations/](migrations/)):nnew file mode 100644@@@ -0,0 +1,16 @@++/* OpenVibe.Reviews — /everywhere only (every review site's own search), so reviews.css stays inside its budget. */++.rv-ew-form { display: grid; gap: 10px; max-width: 640px; margin: 14px 0 18px; }++.rv-ew-row { display: grid; gap: 4px; }++.rv-ew-row label { font-weight: 700; font-size: .9rem; }++.rv-ew-row input, .rv-ew-row select { font: inherit; padding: 9px 11px; border-radius: 10px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); }++.rv-ew-form button { font: inherit; font-weight: 700; padding: 9px 16px; border-radius: 10px; border: 0; background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); cursor: pointer; }++.rv-ew-kinds { display: flex; flex-wrap: wrap; gap: 8px; margin: 6px 0 14px; }++.rv-ew-kind { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); text-decoration: none; color: inherit; font-size: .9rem; }++.rv-ew-kind.on { border-color: var(--accent, #3b82f6); font-weight: 700; }++.rv-ew-sites { list-style: none; padding: 0; margin: 0 0 10px; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }++.rv-ew-site { display: flex; flex-direction: column; gap: 6px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }++.rv-ew-site:hover { border-color: var(--accent, #3b82f6); }++.rv-ew-open { display: flex; flex-direction: column; gap: 2px; text-decoration: none; color: var(--text-primary, #e6edf7); }++.rv-ew-name { font-weight: 700; font-size: 1.05rem; }++.rv-ew-host { font-size: .82rem; color: var(--accent-text, var(--accent, #3b82f6)); }++.rv-ew-best { margin: 0; font-size: .9rem; color: var(--text-secondary, #96a7c2); }@@@ -153,6 +153,15 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi});// What shipped on OpenVibe.Reviews: the shared update log every OpenVibe site has.router.get('/updates', (req, res) => send(req, res, 200, frame.updatesBody({ service: 'reviews', siteName: 'OpenVibe.Reviews' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`, { title: 'What shipped on OpenVibe.Reviews', robots: 'index, follow', cache: 'public', path: '/updates' }));++ router.get('/everywhere', (req, res) => {++ const ew = require('../reviews/everywhere');++ const q = ew.clean(req.query.q, 120);++ const kind = ew.KINDS.includes(String(req.query.kind)) ? String(req.query.kind) : 'business';++ const near = ew.clean(req.query.near, 80);++ send(req, res, 200, views.everywherePage({ q, kind, near, links: ew.linksFor(q, { kind, near }), KINDS: ew.KINDS, KIND_TEXT: ew.KIND_TEXT }), {++ title: q ? `“${q}” on every review site` : 'Every review site, one search', robots: q ? 'noindex, follow' : 'index, follow', cache: 'public', active: 'everywhere', localStyles: ['css/everywhere.css'],++ });++ });router.get('/about', (req, res) => send(req, res, 200, views.aboutPage(), { title: 'How it works', robots: 'index, follow', cache: 'public', active: 'about' }));router.get('/search', async (req, res) => {const query = String(req.query.q || '').slice(0, 200);@@@ -39,6 +39,7 @@ function setRelease(id) { if (id) RELEASE = String(id); }const LINKS = [{ label: 'Entities', href: '/', key: 'home' },++ { label: 'Every review site', href: '/everywhere', key: 'everywhere' },{ label: 'How it works', href: '/about', key: 'about' },];@@@ -97,6 +98,8 @@ function renderPage(o) {home: '/',css: asset('css/reviews.css'),styles: o.styles, // openvibe-shared stylesheet names (the home page's showcase.css)++ // This service's own extra stylesheets for one page (/everywhere's css/everywhere.css).++ head: (o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`).join('\n'),release: RELEASE,mainClass: 'rv-main',header: `<header class="rv-bar"><a class="rv-brand" href="/">${SITE_NAME}</a><form class="rv-search" action="/search" method="get" role="search"><label for="rv-q" class="rv-sr">Search entities</label><input id="rv-q" name="q" type="search" placeholder="Search entities" value="${esc(o.query || '')}"><button type="submit">Search</button></form><noscript><span class="rv-account">${who}</span></noscript></header>`,@@@ -78,7 +78,7 @@ function reviewsShowcase() {eyebrow: 'OpenVibe.Reviews',title: 'Review scores you can', accent: 'trace',lede: 'Ratings and recommendations gathered from named sources. Every number shows where it came from and when it was read, and when no source has said anything there is no score at all.',-- actions: [{ label: 'Browse what is reviewed', href: '#entities', primary: true }, { label: 'How it works', href: '/about' }],++ actions: [{ label: 'Browse what is reviewed', href: '#entities', primary: true }, { label: 'Search every review site', href: '/everywhere' }, { label: 'How it works', href: '/about' }],}) + showcase.features({title: 'What every page shows',items: [@@@ -126,6 +126,23 @@ ${entities.length ? html`<p class="sc-lede">Each score comes from named sources.<p class="rv-muted">${n(total)} reviewed in total. <a href="/feed.atom">Atom</a> · <a href="/feed.json">JSON Feed</a>${editor ? html` · <a href="/editor">Editor desk</a>` : ''}</p></section>`;}++/** /everywhere: one name opens every review site's own search for it (server/reviews/everywhere.js). */++function everywherePage({ q, kind, near, links, KINDS, KIND_TEXT }) {++ const host = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return ''; } };++ const tab = (k) => `/everywhere?${new URLSearchParams({ q, kind: k, ...(near ? { near } : {}) })}`;++ return html`<section><h1>${q ? html`“${q}” on every review site` : 'Every review site, one search'}</h1>++<p class="rv-lede">Type a name once: each review site opens its own search for it, in a new tab. The reviews stay on those sites, under their own rules; OpenVibe.Reviews copies none of them.</p>++<form class="rv-ew-form" method="get" action="/everywhere" role="search">++<div class="rv-ew-row"><label for="ew-q">Name</label><input id="ew-q" name="q" type="search" required maxlength="120" placeholder="A business, an employer, a landlord, a product" value="${q}"></div>++<div class="rv-ew-row"><label for="ew-kind">It is</label><select id="ew-kind" name="kind">${KINDS.map((k) => html`<option value="${k}"${k === kind ? raw(' selected') : ''}>${KIND_TEXT[k]}</option>`)}</select></div>++<div class="rv-ew-row"><label for="ew-near">Near (for a local business or landlord)</label><input id="ew-near" name="near" type="text" maxlength="80" placeholder="City and state" value="${near}"></div>++<p><button type="submit">Search every site</button></p></form>++${q ? html`<nav class="rv-ew-kinds" aria-label="What it is">${KINDS.map((k) => (k === kind ? html`<span class="rv-ew-kind on" aria-current="page">${KIND_TEXT[k]}</span>` : html`<a class="rv-ew-kind" href="${tab(k)}">${KIND_TEXT[k]}</a>`))}</nav>++<ul class="rv-ew-sites">${links.map((l) => html`<li class="rv-ew-site"><a class="rv-ew-open" href="${l.url}" target="_blank" rel="noopener nofollow"><span class="rv-ew-name">${l.name}</span><span class="rv-ew-host">${host(l.url)} ↗</span></a><p class="rv-ew-best">${l.best}</p></li>`)}</ul>++<p class="rv-muted">Read more than one: each site has its own audience and its own rules about who may review.</p>` : ''}++</section>`;++}++function aboutPage() {return html`<section><h1>How OpenVibe.Reviews works</h1><h2 id="signals">Signals</h2>@@@ -465,6 +482,7 @@ function itemPage({ item, candidates, error = null }) {}module.exports = {++ everywherePage,home, aboutPage, searchPage, entityPage, historyPage, summaryRevisionPage, correctionPage,editorHome, newEntityPage, editEntityPage, itemPage, signInPage, errorBody, epath, signalValue, KIND_LABEL,};nnew file mode 100644@@@ -0,0 +1,74 @@++'use strict';++++/**++ * Every review site, one search (/everywhere): the owner's April ask for Reviews was aggregation across Glassdoor, Yelp,++ * BBB, Reddit and the rest "subject to legal and technical feasibility". Their terms forbid copying their reviews, so++ * Reviews does what they allow: one name (and, for a local business, a place) opens each site's own search for it, in++ * a new tab. Reviews stores nothing and fetches nothing from them.++ *++ * The URL forms were opened in a real headless Chromium on 2026-10-15: Google Maps, BBB, Glassdoor, Indeed, Blind,++ * LowEndTalk, YouTube, RTINGS, Amazon, Product Hunt and Reddit answered with results; Yelp, Trustpilot, Tripadvisor, G2++ * and Capterra stop headless browsers at a bot check, and these are their own search addresses for people. Left out:++ * WebHostingTalk (its search is a POST), ApartmentRatings and Rate My Landlord (no search address that works).++ *++ * KINDS what a person is looking up++ * SITES id, name, kinds, best (what the site is good for), url(q, near) → URL++ * linksFor(q, { kind, near }) → [{ id, name, url, best }]++ */++++const enc = encodeURIComponent;++const plus = (s) => enc(s).replace(/%20/g, '+');++const withNear = (q, near) => (near ? `${q} ${near}` : q);++++const KINDS = ['business', 'employer', 'landlord', 'company', 'hosting', 'product', 'software'];++const KIND_TEXT = {++ business: 'A local business', employer: 'An employer', landlord: 'A landlord or building', company: 'A company or online shop',++ hosting: 'A hosting provider', product: 'A product', software: 'Software or an app',++};++++const SITES = [++ { id: 'google', name: 'Google Maps', kinds: ['business', 'landlord'], best: 'Star ratings and reviews on the map, for nearly every place.',++ url: (q, near) => `https://www.google.com/maps/search/${plus(withNear(q, near))}` },++ { id: 'yelp', name: 'Yelp', kinds: ['business', 'landlord'], best: 'Restaurants, shops and local services, with photos.',++ url: (q, near) => `https://www.yelp.com/search?find_desc=${enc(q)}${near ? `&find_loc=${enc(near)}` : ''}` },++ { id: 'tripadvisor', name: 'Tripadvisor', kinds: ['business'], best: 'Restaurants, hotels and things to do, from travellers.',++ url: (q, near) => `https://www.tripadvisor.com/Search?q=${enc(withNear(q, near))}` },++ { id: 'bbb', name: 'Better Business Bureau', kinds: ['business', 'landlord', 'company'], best: 'Complaints, how the business answered them, and its BBB rating.',++ url: (q, near) => `https://www.bbb.org/search?find_text=${enc(q)}${near ? `&find_loc=${enc(near)}` : ''}` },++ { id: 'trustpilot', name: 'Trustpilot', kinds: ['company', 'hosting'], best: 'Customer reviews of companies and online shops.',++ url: (q) => `https://www.trustpilot.com/search?query=${enc(q)}` },++ { id: 'glassdoor', name: 'Glassdoor', kinds: ['employer'], best: 'What employees say: ratings, pay and interviews.',++ url: (q) => `https://www.glassdoor.com/Search/results.htm?keyword=${enc(q)}` },++ { id: 'indeed', name: 'Indeed', kinds: ['employer'], best: 'Company reviews and ratings from people who worked there.',++ url: (q) => `https://www.indeed.com/companies/search?q=${enc(q)}` },++ { id: 'blind', name: 'Blind', kinds: ['employer'], best: 'Anonymous talk from verified employees, mostly tech.',++ url: (q) => `https://www.teamblind.com/search/${enc(q)}` },++ { id: 'lowendtalk', name: 'LowEndTalk', kinds: ['hosting'], best: 'Hosting customers on uptime, support and offers.',++ url: (q) => `https://lowendtalk.com/search?Search=${enc(q)}` },++ { id: 'rtings', name: 'RTINGS', kinds: ['product'], best: 'Lab-measured reviews of TVs, headphones, monitors and more.',++ url: (q) => `https://www.rtings.com/search?q=${enc(q)}` },++ { id: 'youtube', name: 'YouTube reviews', kinds: ['product', 'software'], best: 'Video reviews and hands-on tests.',++ url: (q) => `https://www.youtube.com/results?search_query=${plus(`${q} review`)}` },++ { id: 'amazon', name: 'Amazon', kinds: ['product'], best: 'Buyer ratings and reviews of products sold there.',++ url: (q) => `https://www.amazon.com/s?k=${plus(q)}` },++ { id: 'g2', name: 'G2', kinds: ['software'], best: 'Business software reviews by its users.',++ url: (q) => `https://www.g2.com/search?query=${enc(q)}` },++ { id: 'capterra', name: 'Capterra', kinds: ['software'], best: 'Software reviews with ratings for features and support.',++ url: (q) => `https://www.capterra.com/search/?query=${enc(q)}` },++ { id: 'producthunt', name: 'Product Hunt', kinds: ['software'], best: 'New apps and tools, with comments from early users.',++ url: (q) => `https://www.producthunt.com/search?q=${enc(q)}` },++ { id: 'reddit', name: 'Reddit', kinds: KINDS, best: 'Unfiltered threads from people who used it.',++ url: (q) => `https://www.reddit.com/search/?q=${enc(`${q} review`)}` },++];++++const clean = (v, max) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);++++function linksFor(q, { kind = 'business', near = '' } = {}) {++ const query = clean(q, 120);++ if (!query) return [];++ const k = KINDS.includes(kind) ? kind : 'business';++ const place = clean(near, 80);++ return SITES.filter((s) => s.kinds.includes(k)).map((s) => ({ id: s.id, name: s.name, url: s.url(query, place), best: s.best }));++}++++module.exports = { KINDS, KIND_TEXT, SITES, linksFor, clean };nnew file mode 100644@@@ -0,0 +1,45 @@++'use strict';++/**++ * Every review site, one search (server/reviews/everywhere.js, /everywhere): a name and a kind open each review site's++ * own search, in new tabs (the URL forms checked in a real browser on 2026-10-15), with the place for local businesses++ * and landlords; nothing is fetched or stored; a result page is not indexed; the home and the navigation link it.++ */++const assert = require('assert');++const { boot, req, suite } = require('./helpers');++const ew = require('../server/reviews/everywhere');++++const t = suite('everywhere');++++t('each kind opens the review sites that cover it, with the place where a site takes one', async () => {++ const urls = (q, o) => Object.fromEntries(ew.linksFor(q, o).map((l) => [l.id, l.url]));++ const biz = urls("Joe's Pizza", { kind: 'business', near: 'Portland, OR' });++ assert.deepStrictEqual(Object.keys(biz), ['google', 'yelp', 'tripadvisor', 'bbb', 'reddit']);++ assert.strictEqual(biz.yelp, "https://www.yelp.com/search?find_desc=Joe's%20Pizza&find_loc=Portland%2C%20OR");++ assert.strictEqual(biz.google, "https://www.google.com/maps/search/Joe's+Pizza+Portland%2C+OR");++ const job = urls('Comcast', { kind: 'employer' });++ assert.deepStrictEqual(Object.keys(job), ['glassdoor', 'indeed', 'blind', 'reddit']);++ assert.strictEqual(job.glassdoor, 'https://www.glassdoor.com/Search/results.htm?keyword=Comcast');++ assert.deepStrictEqual(Object.keys(urls('Hetzner', { kind: 'hosting' })), ['trustpilot', 'lowendtalk', 'reddit']);++ assert.deepStrictEqual(Object.keys(urls('Pixel 9', { kind: 'product' })), ['rtings', 'youtube', 'amazon', 'reddit']);++ assert.strictEqual(urls('Pixel 9', { kind: 'product' }).youtube, 'https://www.youtube.com/results?search_query=Pixel+9+review');++ assert.deepStrictEqual(Object.keys(urls('Slack', { kind: 'software' })), ['youtube', 'g2', 'capterra', 'producthunt', 'reddit']);++ assert.deepStrictEqual(ew.linksFor(' '), []);++ assert.deepStrictEqual(Object.keys(urls('x', { kind: 'boats' })), ['google', 'yelp', 'tripadvisor', 'bbb', 'reddit'], 'an unknown kind is a local business');++ ew.KINDS.forEach((k) => ew.linksFor('a & b', { kind: k, near: 'c' }).forEach((l) => assert.match(l.url, /^https:\/\/[a-z0-9.-]+\//, l.url)));++});++++t('/everywhere: the sites in new tabs, the kinds, not indexed with a query; linked from the home', async () => {++ const h = await boot();++ try {++ const r = await req(h, 'GET', '/everywhere?q=Comcast&kind=employer');++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));++ assert.ok(r.text.includes('href="https://www.glassdoor.com/Search/results.htm?keyword=Comcast" target="_blank" rel="noopener nofollow"'));++ assert.ok(r.text.includes('noindex'));++ assert.ok(r.text.includes('/everywhere?q=Comcast&kind=hosting'), 'the other kinds keep the name');++ const blank = await req(h, 'GET', '/everywhere');++ assert.ok(blank.status === 200 && !blank.text.includes('glassdoor.com') && blank.text.includes('Every review site, one search'));++ const home = await req(h, 'GET', '/');++ assert.ok(home.text.includes('href="/everywhere"'));++ } finally { await h.stop(); }++});++
@@ -16,6 +16,17 @@ only from the signals that exist. It **never fabricates a rating and never prese
@@ -16,6 +16,17 @@ only from the signals that exist. It **never fabricates a rating and never prese
user reviews**: summaries are text written or approved by a person, every point cites the signals
user reviews**: summaries are text written or approved by a person, every point cites the signals
it rests on, and nothing a summary says can become a number.
it rests on, and nothing a summary says can become a number.
+## Every review site, one search
+
+The owner's April ask was review aggregation across Glassdoor, Yelp, BBB, Reddit and the rest, "subject to legal and
+technical feasibility". Their terms forbid copying their reviews, so `/everywhere`
+([server/reviews/everywhere.js](server/reviews/everywhere.js)) does what they allow: one name, its kind (a local
+business, an employer, a landlord, a company or online shop, a hosting provider, a product, software) and, for local
+places, where it is, open each site's own search in new tabs: Google Maps, Yelp, Tripadvisor, BBB, Trustpilot,
+Glassdoor, Indeed, Blind, LowEndTalk, RTINGS, YouTube reviews, Amazon, G2, Capterra, Product Hunt and Reddit. The URL
+forms were checked in a real browser on 2026-10-15 (WebHostingTalk, ApartmentRatings and Rate My Landlord have no
+search address that works). Reviews fetches and stores nothing from them; a result page is `noindex`.
+
## Owns
## Owns
The nine authority tables of §15.13, in Reviews' own PostgreSQL database (`ov_reviews` on the host's data role, ADR-035; schema in [migrations/](migrations/)):
The nine authority tables of §15.13, in Reviews' own PostgreSQL database (`ov_reviews` on the host's data role, ADR-035; schema in [migrations/](migrations/)):
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+/* OpenVibe.Reviews — /everywhere only (every review site's own search), so reviews.css stays inside its budget. */
+.rv-ew-form { display: grid; gap: 10px; max-width: 640px; margin: 14px 0 18px; }
+.rv-ew-row { display: grid; gap: 4px; }
+.rv-ew-row label { font-weight: 700; font-size: .9rem; }
+.rv-ew-row input, .rv-ew-row select { font: inherit; padding: 9px 11px; border-radius: 10px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); }
+.rv-ew-form button { font: inherit; font-weight: 700; padding: 9px 16px; border-radius: 10px; border: 0; background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); cursor: pointer; }
+.rv-ew-kinds { display: flex; flex-wrap: wrap; gap: 8px; margin: 6px 0 14px; }
+.rv-ew-kind { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); text-decoration: none; color: inherit; font-size: .9rem; }
+.rv-ew-kind.on { border-color: var(--accent, #3b82f6); font-weight: 700; }
+.rv-ew-sites { list-style: none; padding: 0; margin: 0 0 10px; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
+.rv-ew-site { display: flex; flex-direction: column; gap: 6px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
+.rv-ew-site:hover { border-color: var(--accent, #3b82f6); }
+.rv-ew-open { display: flex; flex-direction: column; gap: 2px; text-decoration: none; color: var(--text-primary, #e6edf7); }
+.rv-ew-name { font-weight: 700; font-size: 1.05rem; }
+.rv-ew-host { font-size: .82rem; color: var(--accent-text, var(--accent, #3b82f6)); }
+.rv-ew-best { margin: 0; font-size: .9rem; color: var(--text-secondary, #96a7c2); }
@@ -153,6 +153,15 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
@@ -153,6 +153,15 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
});
});
// What shipped on OpenVibe.Reviews: the shared update log every OpenVibe site has.
// What shipped on OpenVibe.Reviews: the shared update log every OpenVibe site has.
router.get('/updates', (req, res) => send(req, res, 200, frame.updatesBody({ service: 'reviews', siteName: 'OpenVibe.Reviews' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`, { title: 'What shipped on OpenVibe.Reviews', robots: 'index, follow', cache: 'public', path: '/updates' }));
router.get('/updates', (req, res) => send(req, res, 200, frame.updatesBody({ service: 'reviews', siteName: 'OpenVibe.Reviews' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`, { title: 'What shipped on OpenVibe.Reviews', robots: 'index, follow', cache: 'public', path: '/updates' }));
+ router.get('/everywhere', (req, res) => {
+ const ew = require('../reviews/everywhere');
+ const q = ew.clean(req.query.q, 120);
+ const kind = ew.KINDS.includes(String(req.query.kind)) ? String(req.query.kind) : 'business';
+ const near = ew.clean(req.query.near, 80);
+ send(req, res, 200, views.everywherePage({ q, kind, near, links: ew.linksFor(q, { kind, near }), KINDS: ew.KINDS, KIND_TEXT: ew.KIND_TEXT }), {
+ title: q ? `“${q}” on every review site` : 'Every review site, one search', robots: q ? 'noindex, follow' : 'index, follow', cache: 'public', active: 'everywhere', localStyles: ['css/everywhere.css'],
+ });
+ });
router.get('/about', (req, res) => send(req, res, 200, views.aboutPage(), { title: 'How it works', robots: 'index, follow', cache: 'public', active: 'about' }));
router.get('/about', (req, res) => send(req, res, 200, views.aboutPage(), { title: 'How it works', robots: 'index, follow', cache: 'public', active: 'about' }));
router.get('/search', async (req, res) => {
router.get('/search', async (req, res) => {
const query = String(req.query.q || '').slice(0, 200);
const query = String(req.query.q || '').slice(0, 200);
@@ -39,6 +39,7 @@ function setRelease(id) { if (id) RELEASE = String(id); }
@@ -39,6 +39,7 @@ function setRelease(id) { if (id) RELEASE = String(id); }
const LINKS = [
const LINKS = [
{ label: 'Entities', href: '/', key: 'home' },
{ label: 'Entities', href: '/', key: 'home' },
+ { label: 'Every review site', href: '/everywhere', key: 'everywhere' },
{ label: 'How it works', href: '/about', key: 'about' },
{ label: 'How it works', href: '/about', key: 'about' },
];
];
@@ -97,6 +98,8 @@ function renderPage(o) {
@@ -97,6 +98,8 @@ function renderPage(o) {
home: '/',
home: '/',
css: asset('css/reviews.css'),
css: asset('css/reviews.css'),
styles: o.styles, // openvibe-shared stylesheet names (the home page's showcase.css)
styles: o.styles, // openvibe-shared stylesheet names (the home page's showcase.css)
+ // This service's own extra stylesheets for one page (/everywhere's css/everywhere.css).
+ head: (o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`).join('\n'),
release: RELEASE,
release: RELEASE,
mainClass: 'rv-main',
mainClass: 'rv-main',
header: `<header class="rv-bar"><a class="rv-brand" href="/">${SITE_NAME}</a><form class="rv-search" action="/search" method="get" role="search"><label for="rv-q" class="rv-sr">Search entities</label><input id="rv-q" name="q" type="search" placeholder="Search entities" value="${esc(o.query || '')}"><button type="submit">Search</button></form><noscript><span class="rv-account">${who}</span></noscript></header>`,
header: `<header class="rv-bar"><a class="rv-brand" href="/">${SITE_NAME}</a><form class="rv-search" action="/search" method="get" role="search"><label for="rv-q" class="rv-sr">Search entities</label><input id="rv-q" name="q" type="search" placeholder="Search entities" value="${esc(o.query || '')}"><button type="submit">Search</button></form><noscript><span class="rv-account">${who}</span></noscript></header>`,
@@ -78,7 +78,7 @@ function reviewsShowcase() {
@@ -78,7 +78,7 @@ function reviewsShowcase() {
eyebrow: 'OpenVibe.Reviews',
eyebrow: 'OpenVibe.Reviews',
title: 'Review scores you can', accent: 'trace',
title: 'Review scores you can', accent: 'trace',
lede: 'Ratings and recommendations gathered from named sources. Every number shows where it came from and when it was read, and when no source has said anything there is no score at all.',
lede: 'Ratings and recommendations gathered from named sources. Every number shows where it came from and when it was read, and when no source has said anything there is no score at all.',
- actions: [{ label: 'Browse what is reviewed', href: '#entities', primary: true }, { label: 'How it works', href: '/about' }],
+ actions: [{ label: 'Browse what is reviewed', href: '#entities', primary: true }, { label: 'Search every review site', href: '/everywhere' }, { label: 'How it works', href: '/about' }],
}) + showcase.features({
}) + showcase.features({
title: 'What every page shows',
title: 'What every page shows',
items: [
items: [
@@ -126,6 +126,23 @@ ${entities.length ? html`<p class="sc-lede">Each score comes from named sources.
@@ -126,6 +126,23 @@ ${entities.length ? html`<p class="sc-lede">Each score comes from named sources.
<p class="rv-muted">${n(total)} reviewed in total. <a href="/feed.atom">Atom</a> · <a href="/feed.json">JSON Feed</a>${editor ? html` · <a href="/editor">Editor desk</a>` : ''}</p></section>`;
<p class="rv-muted">${n(total)} reviewed in total. <a href="/feed.atom">Atom</a> · <a href="/feed.json">JSON Feed</a>${editor ? html` · <a href="/editor">Editor desk</a>` : ''}</p></section>`;
}
}
+/** /everywhere: one name opens every review site's own search for it (server/reviews/everywhere.js). */
+function everywherePage({ q, kind, near, links, KINDS, KIND_TEXT }) {
+ const host = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return ''; } };
+ const tab = (k) => `/everywhere?${new URLSearchParams({ q, kind: k, ...(near ? { near } : {}) })}`;
+ return html`<section><h1>${q ? html`“${q}” on every review site` : 'Every review site, one search'}</h1>
+<p class="rv-lede">Type a name once: each review site opens its own search for it, in a new tab. The reviews stay on those sites, under their own rules; OpenVibe.Reviews copies none of them.</p>
+<form class="rv-ew-form" method="get" action="/everywhere" role="search">
+<div class="rv-ew-row"><label for="ew-q">Name</label><input id="ew-q" name="q" type="search" required maxlength="120" placeholder="A business, an employer, a landlord, a product" value="${q}"></div>
+<div class="rv-ew-row"><label for="ew-kind">It is</label><select id="ew-kind" name="kind">${KINDS.map((k) => html`<option value="${k}"${k === kind ? raw(' selected') : ''}>${KIND_TEXT[k]}</option>`)}</select></div>
+<div class="rv-ew-row"><label for="ew-near">Near (for a local business or landlord)</label><input id="ew-near" name="near" type="text" maxlength="80" placeholder="City and state" value="${near}"></div>
+<p><button type="submit">Search every site</button></p></form>
+${q ? html`<nav class="rv-ew-kinds" aria-label="What it is">${KINDS.map((k) => (k === kind ? html`<span class="rv-ew-kind on" aria-current="page">${KIND_TEXT[k]}</span>` : html`<a class="rv-ew-kind" href="${tab(k)}">${KIND_TEXT[k]}</a>`))}</nav>
+<ul class="rv-ew-sites">${links.map((l) => html`<li class="rv-ew-site"><a class="rv-ew-open" href="${l.url}" target="_blank" rel="noopener nofollow"><span class="rv-ew-name">${l.name}</span><span class="rv-ew-host">${host(l.url)} ↗</span></a><p class="rv-ew-best">${l.best}</p></li>`)}</ul>
+<p class="rv-muted">Read more than one: each site has its own audience and its own rules about who may review.</p>` : ''}
+</section>`;
+}
+
function aboutPage() {
function aboutPage() {
return html`<section><h1>How OpenVibe.Reviews works</h1>
return html`<section><h1>How OpenVibe.Reviews works</h1>
<h2 id="signals">Signals</h2>
<h2 id="signals">Signals</h2>
@@ -465,6 +482,7 @@ function itemPage({ item, candidates, error = null }) {
@@ -465,6 +482,7 @@ function itemPage({ item, candidates, error = null }) {
}
}
module.exports = {
module.exports = {
+ everywherePage,
home, aboutPage, searchPage, entityPage, historyPage, summaryRevisionPage, correctionPage,
home, aboutPage, searchPage, entityPage, historyPage, summaryRevisionPage, correctionPage,
editorHome, newEntityPage, editEntityPage, itemPage, signInPage, errorBody, epath, signalValue, KIND_LABEL,
editorHome, newEntityPage, editEntityPage, itemPage, signInPage, errorBody, epath, signalValue, KIND_LABEL,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
+'use strict';
+
+/**
+ * Every review site, one search (/everywhere): the owner's April ask for Reviews was aggregation across Glassdoor, Yelp,
+ * BBB, Reddit and the rest "subject to legal and technical feasibility". Their terms forbid copying their reviews, so
+ * Reviews does what they allow: one name (and, for a local business, a place) opens each site's own search for it, in
+ * a new tab. Reviews stores nothing and fetches nothing from them.
+ *
+ * The URL forms were opened in a real headless Chromium on 2026-10-15: Google Maps, BBB, Glassdoor, Indeed, Blind,
+ * LowEndTalk, YouTube, RTINGS, Amazon, Product Hunt and Reddit answered with results; Yelp, Trustpilot, Tripadvisor, G2
+ * and Capterra stop headless browsers at a bot check, and these are their own search addresses for people. Left out:
+ * WebHostingTalk (its search is a POST), ApartmentRatings and Rate My Landlord (no search address that works).
+ *
+ * KINDS what a person is looking up
+ * SITES id, name, kinds, best (what the site is good for), url(q, near) → URL
+ * linksFor(q, { kind, near }) → [{ id, name, url, best }]
+ */
+
+const enc = encodeURIComponent;
+const plus = (s) => enc(s).replace(/%20/g, '+');
+const withNear = (q, near) => (near ? `${q} ${near}` : q);
+
+const KINDS = ['business', 'employer', 'landlord', 'company', 'hosting', 'product', 'software'];
+const KIND_TEXT = {
+ business: 'A local business', employer: 'An employer', landlord: 'A landlord or building', company: 'A company or online shop',
+ hosting: 'A hosting provider', product: 'A product', software: 'Software or an app',
+};
+
+const SITES = [
+ { id: 'google', name: 'Google Maps', kinds: ['business', 'landlord'], best: 'Star ratings and reviews on the map, for nearly every place.',
+ url: (q, near) => `https://www.google.com/maps/search/${plus(withNear(q, near))}` },
+ { id: 'yelp', name: 'Yelp', kinds: ['business', 'landlord'], best: 'Restaurants, shops and local services, with photos.',
+ url: (q, near) => `https://www.yelp.com/search?find_desc=${enc(q)}${near ? `&find_loc=${enc(near)}` : ''}` },
+ { id: 'tripadvisor', name: 'Tripadvisor', kinds: ['business'], best: 'Restaurants, hotels and things to do, from travellers.',
+ url: (q, near) => `https://www.tripadvisor.com/Search?q=${enc(withNear(q, near))}` },
+ { id: 'bbb', name: 'Better Business Bureau', kinds: ['business', 'landlord', 'company'], best: 'Complaints, how the business answered them, and its BBB rating.',
+ url: (q, near) => `https://www.bbb.org/search?find_text=${enc(q)}${near ? `&find_loc=${enc(near)}` : ''}` },
+ { id: 'trustpilot', name: 'Trustpilot', kinds: ['company', 'hosting'], best: 'Customer reviews of companies and online shops.',
+ url: (q) => `https://www.trustpilot.com/search?query=${enc(q)}` },
+ { id: 'glassdoor', name: 'Glassdoor', kinds: ['employer'], best: 'What employees say: ratings, pay and interviews.',
+ url: (q) => `https://www.glassdoor.com/Search/results.htm?keyword=${enc(q)}` },
+ { id: 'indeed', name: 'Indeed', kinds: ['employer'], best: 'Company reviews and ratings from people who worked there.',
+ url: (q) => `https://www.indeed.com/companies/search?q=${enc(q)}` },
+ { id: 'blind', name: 'Blind', kinds: ['employer'], best: 'Anonymous talk from verified employees, mostly tech.',
+ url: (q) => `https://www.teamblind.com/search/${enc(q)}` },
+ { id: 'lowendtalk', name: 'LowEndTalk', kinds: ['hosting'], best: 'Hosting customers on uptime, support and offers.',
+ url: (q) => `https://lowendtalk.com/search?Search=${enc(q)}` },
+ { id: 'rtings', name: 'RTINGS', kinds: ['product'], best: 'Lab-measured reviews of TVs, headphones, monitors and more.',
+ url: (q) => `https://www.rtings.com/search?q=${enc(q)}` },
+ { id: 'youtube', name: 'YouTube reviews', kinds: ['product', 'software'], best: 'Video reviews and hands-on tests.',
+ url: (q) => `https://www.youtube.com/results?search_query=${plus(`${q} review`)}` },
+ { id: 'amazon', name: 'Amazon', kinds: ['product'], best: 'Buyer ratings and reviews of products sold there.',
+ url: (q) => `https://www.amazon.com/s?k=${plus(q)}` },
+ { id: 'g2', name: 'G2', kinds: ['software'], best: 'Business software reviews by its users.',
+ url: (q) => `https://www.g2.com/search?query=${enc(q)}` },
+ { id: 'capterra', name: 'Capterra', kinds: ['software'], best: 'Software reviews with ratings for features and support.',
+ url: (q) => `https://www.capterra.com/search/?query=${enc(q)}` },
+ { id: 'producthunt', name: 'Product Hunt', kinds: ['software'], best: 'New apps and tools, with comments from early users.',
+ url: (q) => `https://www.producthunt.com/search?q=${enc(q)}` },
+ { id: 'reddit', name: 'Reddit', kinds: KINDS, best: 'Unfiltered threads from people who used it.',
+ url: (q) => `https://www.reddit.com/search/?q=${enc(`${q} review`)}` },
+];
+
+const clean = (v, max) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
+
+function linksFor(q, { kind = 'business', near = '' } = {}) {
+ const query = clean(q, 120);
+ if (!query) return [];
+ const k = KINDS.includes(kind) ? kind : 'business';
+ const place = clean(near, 80);
+ return SITES.filter((s) => s.kinds.includes(k)).map((s) => ({ id: s.id, name: s.name, url: s.url(query, place), best: s.best }));
+}
+
+module.exports = { KINDS, KIND_TEXT, SITES, linksFor, clean };
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+'use strict';
+/**
+ * Every review site, one search (server/reviews/everywhere.js, /everywhere): a name and a kind open each review site's
+ * own search, in new tabs (the URL forms checked in a real browser on 2026-10-15), with the place for local businesses
+ * and landlords; nothing is fetched or stored; a result page is not indexed; the home and the navigation link it.
+ */
+const assert = require('assert');
+const { boot, req, suite } = require('./helpers');
+const ew = require('../server/reviews/everywhere');
+
+const t = suite('everywhere');
+
+t('each kind opens the review sites that cover it, with the place where a site takes one', async () => {
+ const urls = (q, o) => Object.fromEntries(ew.linksFor(q, o).map((l) => [l.id, l.url]));
+ const biz = urls("Joe's Pizza", { kind: 'business', near: 'Portland, OR' });
+ assert.deepStrictEqual(Object.keys(biz), ['google', 'yelp', 'tripadvisor', 'bbb', 'reddit']);
+ assert.strictEqual(biz.yelp, "https://www.yelp.com/search?find_desc=Joe's%20Pizza&find_loc=Portland%2C%20OR");
+ assert.strictEqual(biz.google, "https://www.google.com/maps/search/Joe's+Pizza+Portland%2C+OR");
+ const job = urls('Comcast', { kind: 'employer' });
+ assert.deepStrictEqual(Object.keys(job), ['glassdoor', 'indeed', 'blind', 'reddit']);
+ assert.strictEqual(job.glassdoor, 'https://www.glassdoor.com/Search/results.htm?keyword=Comcast');
+ assert.deepStrictEqual(Object.keys(urls('Hetzner', { kind: 'hosting' })), ['trustpilot', 'lowendtalk', 'reddit']);
+ assert.deepStrictEqual(Object.keys(urls('Pixel 9', { kind: 'product' })), ['rtings', 'youtube', 'amazon', 'reddit']);
+ assert.strictEqual(urls('Pixel 9', { kind: 'product' }).youtube, 'https://www.youtube.com/results?search_query=Pixel+9+review');
+ assert.deepStrictEqual(Object.keys(urls('Slack', { kind: 'software' })), ['youtube', 'g2', 'capterra', 'producthunt', 'reddit']);
+ assert.deepStrictEqual(ew.linksFor(' '), []);
+ assert.deepStrictEqual(Object.keys(urls('x', { kind: 'boats' })), ['google', 'yelp', 'tripadvisor', 'bbb', 'reddit'], 'an unknown kind is a local business');
+ ew.KINDS.forEach((k) => ew.linksFor('a & b', { kind: k, near: 'c' }).forEach((l) => assert.match(l.url, /^https:\/\/[a-z0-9.-]+\//, l.url)));
+});
+
+t('/everywhere: the sites in new tabs, the kinds, not indexed with a query; linked from the home', async () => {
+ const h = await boot();
+ try {
+ const r = await req(h, 'GET', '/everywhere?q=Comcast&kind=employer');
+ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
+ assert.ok(r.text.includes('href="https://www.glassdoor.com/Search/results.htm?keyword=Comcast" target="_blank" rel="noopener nofollow"'));
+ assert.ok(r.text.includes('noindex'));
+ assert.ok(r.text.includes('/everywhere?q=Comcast&kind=hosting'), 'the other kinds keep the name');
+ const blank = await req(h, 'GET', '/everywhere');
+ assert.ok(blank.status === 200 && !blank.text.includes('glassdoor.com') && blank.text.includes('Every review site, one search'));
+ const home = await req(h, 'GET', '/');
+ assert.ok(home.text.includes('href="/everywhere"'));
+ } finally { await h.stop(); }
+});
+