openvibers/openvibe.work Commits

Company pages: /companies by openings and /companies/:slug with the company's listings, places and boards (#63)

OP OpenVibers committed 22 days ago · d08726ca07 · 5 files, +223 −5

Changes

ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 7e889c7..bd824c0 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -15,6 +15,7 @@ const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const store = require('../jobs/store');
++const companies = require('../jobs/companies');
const sources = require('../jobs/sources');
const SITE_NAME = 'OpenVibe.Work';
@@@ -25,6 +26,7 @@ const SITEMAP_JOBS = 1000;
const PAGE_TEXT = {
'/': ['OpenVibe.Work home', 'OpenVibe.Work gathers job listings from public job boards, names the source of every one and links back to the original listing. Search is free and needs no account.'],
'/jobs': ['Jobs', 'Search job listings gathered from Arbeitnow, Remotive and RemoteOK, newest first, filterable by words, workplace, job type and location.'],
++ '/companies': ['Companies hiring', 'Every company with open listings on the boards OpenVibe.Work reads, most openings first, each with a page of its jobs, where they are and which boards list them.'],
'/sources': ['Sources', 'The public job boards OpenVibe.Work gathers from, the terms each is used under as applied here, and when each was last read.'],
'/docs': ['API', 'The OpenVibe.Work API: search the listings, read one with its provenance, list the sources and their terms, keep saved searches.'],
'/updates': ['What shipped on OpenVibe.Work', 'This site\'s update log, from the network changelog feed.'],
@@@ -50,6 +52,7 @@ function homeJsonLd(config) {
const publicPages = () => [
{ path: '/', changefreq: 'daily', priority: 1.0 },
{ path: '/jobs', changefreq: 'hourly', priority: 0.9 },
++ { path: '/companies', changefreq: 'daily', priority: 0.8 },
{ path: '/sources', changefreq: 'weekly', priority: 0.6 },
{ path: '/docs', changefreq: 'monthly', priority: 0.5 },
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
@@@ -111,6 +114,8 @@ function createDiscoveryRoutes(ctx) {
const urls = publicPages().map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));
// The newest listings, so a crawler reaches each board's own page through ours. No lastmod: these entries turn
// over with every ingest, and the site's own freshness is the pages above.
++ // Companies with three or more openings (a thinner page is noindex).
++ for (const c of (await companies.list(s)).filter((x) => x.count >= 3).slice(0, 2000)) urls.push({ loc: abs(`/companies/${c.slug}`), changefreq: 'daily', priority: 0.5 });
for (const row of await store.recentIds(s, SITEMAP_JOBS)) urls.push({ loc: abs(`/jobs/${row.id}`), changefreq: 'daily', priority: 0.4 });
res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
});
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 1f20ecd..be0a499 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -1,7 +1,8 @@
'use strict';
/**
-- * Public pages: the home page, the search (/jobs), one listing (/jobs/:id), the saved searches (/saved), the sources
++ * Public pages: the home page, the search (/jobs), one listing (/jobs/:id), companies (/companies, /companies/:slug),
++ * the saved searches (/saved), the sources
* (/sources) and the update log (/updates). Crawl artifacts (robots.txt, sitemap.xml, llms.txt, llms-full.txt,
* JSON-LD) are http/discovery.js.
*
@@@ -20,6 +21,7 @@ const { html, raw, table, notice, time } = require('../render/html');
const { send } = require('../render/layout');
const listing = require('../render/listing');
const store = require('../jobs/store');
++const companies = require('../jobs/companies');
const sources = require('../jobs/sources');
const applications = require('../applications');
const seo = require('openvibe-shared/seo');
@@@ -163,8 +165,9 @@ ${salaried.length ? html`<section class="sc-sec" aria-labelledby="h-pay"><h2 id=
<p class="sc-lede">${n(sum.salaried)} listing${sum.salaried === 1 ? '' : 's'} say what the job pays, in the board's own words.</p>
${listing.listingList(salaried, { sourceOf, empty: '' })}</section>` : ''}
${sum.hiring.length ? html`<section class="sc-sec" aria-labelledby="h-hiring"><h2 id="h-hiring">Companies with the most openings</h2>
--<p class="sc-lede">Counted across every board. Each opens a search for the company.</p>
--<ul class="home-cards">${sum.hiring.map((c) => html`<li><a href="/jobs?${new URLSearchParams({ q: c.company })}"><b>${c.company}</b><span>${n(c.count)} open listing${c.count === 1 ? '' : 's'}</span></a></li>`)}</ul></section>` : ''}
++<p class="sc-lede">Counted across every board. Each opens the company's page: its openings, where they are and which boards list them.</p>
++<ul class="home-cards">${sum.hiring.map((c) => { const slug = companies.slugOf(c.company); return html`<li><a href="${slug ? `/companies/${slug}` : `/jobs?${new URLSearchParams({ q: c.company })}`}"><b>${c.company}</b><span>${n(c.count)} open listing${c.count === 1 ? '' : 's'}</span></a></li>`; })}</ul>
++<p><a href="/companies">Every company hiring</a></p></section>` : ''}
<section class="sc-sec" aria-labelledby="h-sources"><h2 id="h-sources">Where the listings come from</h2>
<p>Each source below is a public job board read through its own API, under its own terms. Every listing on this site names its source and links to the original; we keep a short excerpt and send you to the board for the rest.</p>
${table(['Source', 'Listings', 'Last read', 'Terms'], sources.SOURCES.map((src) => {
@@@ -280,7 +283,7 @@ ${nextUrl ? html`<a class="sc-btn sc-primary" href="${nextUrl}">Older listings</
jsonLd: [listing.jobPosting(row, { siteUrl: config.baseUrl })],
body: html`<article class="job">
<h1>${l.title}</h1>
--<p class="job-meta"><span class="job-company">${l.company}</span> · <span class="job-location">${l.location || 'Location not given'}</span>${l.remote ? ' · Remote' : ''}${l.job_type ? html` · <span class="job-type">${l.job_type}</span>` : ''}</p>
++<p class="job-meta"><span class="job-company">${companies.slugOf(row.company) ? html`<a href="/companies/${companies.slugOf(row.company)}">${l.company}</a>` : l.company}</span> · <span class="job-location">${l.location || 'Location not given'}</span>${l.remote ? ' · Remote' : ''}${l.job_type ? html` · <span class="job-type">${l.job_type}</span>` : ''}</p>
${l.salary ? html`<p class="job-salary"><strong>Salary:</strong> ${l.salary} <span class="muted small">(as the board gives it)</span></p>` : ''}
${l.tags.length ? html`<ul class="job-tags">${l.tags.map((t) => html`<li>${t}</li>`)}</ul>` : ''}
<section class="job-excerpt-block" aria-labelledby="h-excerpt"><h2 id="h-excerpt">Excerpt</h2>
@@@ -326,6 +329,56 @@ ${l.posted_at ? html`<dt>Posted</dt><dd>${time(l.posted_at)}</dd>` : ''}
return bits.length ? bits.join(' ') : 'everything';
};
++ // ── Companies: every company with open listings, and one page each ──
++ // A company page with fewer than three openings is noindex (thin); the index lists the 300 with the most and finds
++ // any of them by name.
++ const COMPANY_INDEX_MIN = 3;
++ r.get('/companies', async (req, res) => {
++ const all = await companies.list(s);
++ const q = typeof req.query.q === 'string' ? req.query.q.replace(/\s+/g, ' ').trim().slice(0, 80) : '';
++ const shown = (q ? all.filter((c) => c.company.toLowerCase().includes(q.toLowerCase()) || c.slug.includes(companies.slugOf(q))) : all).slice(0, 300);
++ const base = String(config.baseUrl).replace(/\/+$/, '');
++ page(req, res, {
++ index: !q, cache: signedIn(req) ? null : PUBLIC_CACHE,
++ title: q ? `Companies matching “${q}”` : 'Companies hiring',
++ description: `${n(all.length)} companies with open listings on OpenVibe.Work, most openings first, each with its jobs, where they are and which boards list them.`,
++ crumbs: [{ label: 'Companies' }], styles: [showcase.STYLESHEET],
++ jsonLd: q ? [] : [seo.jsonLd.itemList('Companies hiring on OpenVibe.Work', shown.slice(0, 50).map((c) => ({ name: `${c.company}: ${c.count} open listing${c.count === 1 ? '' : 's'}`, url: `${base}/companies/${c.slug}` })), { url: `${base}/companies` })],
++ body: html`<h1>Companies hiring</h1>
++<p class="lede">${n(all.length)} companies have open listings on the boards OpenVibe.Work reads, most openings first.</p>
++<form class="ov-form filter-form" method="get" action="/companies" role="search"><label for="cq">Company</label><input id="cq" name="q" type="search" value="${q}" maxlength="80" placeholder="A company's name"><button class="sc-btn" type="submit">Find</button>${q ? html` <a href="/companies">All companies</a>` : ''}</form>
++${shown.length ? html`<ul class="home-cards">${shown.map((c) => html`<li><a href="/companies/${c.slug}"><b>${c.company}</b><span>${n(c.count)} open listing${c.count === 1 ? '' : 's'}${c.remote ? html`, ${n(c.remote)} remote` : ''}</span></a></li>`)}</ul>`
++ : notice(q ? 'No company with open listings matches that name.' : 'No open listings yet.', 'warn')}
++${!q && all.length > shown.length ? html`<p class="muted small">The ${n(shown.length)} with the most openings. Find any other by name above.</p>` : ''}`,
++ });
++ });
++
++ r.get('/companies/:slug', async (req, res) => {
++ const c = await companies.bySlug(s, req.params.slug);
++ if (!c) {
++ return page(req, res, {
++ title: 'No open listings', crumbs: [{ label: 'Companies', href: '/companies' }, { label: 'Not found' }],
++ body: html`<h1>No open listings</h1><p>No company here has open listings at that address. A company's page goes once the boards stop showing its listings.</p><p><a href="/companies">Every company hiring</a></p>`,
++ }, 404);
++ }
++ const base = String(config.baseUrl).replace(/\/+$/, '');
++ const boards = c.sources.map((x) => { const src = sources.byId(x.source); return html`<a href="/sources#${x.source}">${src ? src.name : x.source}</a> (${n(x.count)})`; });
++ page(req, res, {
++ index: c.count >= COMPANY_INDEX_MIN, cache: signedIn(req) ? null : PUBLIC_CACHE,
++ title: `Jobs at ${c.company}`,
++ description: `${n(c.count)} open listing${c.count === 1 ? '' : 's'} at ${c.company}${c.remote ? `, ${n(c.remote)} remote` : ''}${c.places.length ? `, in ${c.places.slice(0, 3).map((p) => p.place).join(', ')}` : ''}, from the boards OpenVibe.Work reads, each linking back to the original listing.`.slice(0, 300),
++ crumbs: [{ label: 'Companies', href: '/companies' }, { label: c.company }], styles: [showcase.STYLESHEET],
++ jsonLd: [seo.jsonLd.itemList(`Jobs at ${c.company}`, c.rows.slice(0, 50).map((row) => ({ name: `${row.title} at ${row.company}`, url: `${base}/jobs/${row.id}` })), { url: `${base}/companies/${c.slug}` })],
++ body: html`<h1>Jobs at ${c.company}</h1>
++<p class="lede">${n(c.count)} open listing${c.count === 1 ? '' : 's'}${c.remote ? html`, ${n(c.remote)} of them remote` : ''}${c.salaried ? html`, ${n(c.salaried)} stating the pay` : ''}. Listed on ${boards.length > 1 ? html`${boards.slice(0, -1).map((b, i) => html`${i ? ', ' : ''}${b}`)} and ${boards[boards.length - 1]}` : boards[0]}.</p>
++${c.places.length ? html`<p class="small">Where: ${c.places.map((p, i) => html`${i ? ', ' : ''}<a href="/jobs?${new URLSearchParams({ q: c.company, location: p.place })}">${p.place}</a> (${n(p.count)})`)}</p>` : ''}
++<p class="job-alert"><a class="sc-btn" href="${alertHref(store.filtersOf({ q: c.company }))}">Get alerts for ${c.company}</a></p>
++${listing.listingList(c.rows, { sourceOf, empty: 'No open listings.' })}
++${c.count > c.rows.length ? html`<p><a class="sc-btn" href="/jobs?${new URLSearchParams({ q: c.company })}">All ${n(c.count)} in the search</a></p>` : ''}
++<p class="muted small">The company's name as the boards write it. Read each full listing on its board: every entry links back to it.</p>`,
++ });
++ });
++
r.get('/saved', async (req, res) => {
if (!signedIn(req)) {
return page(req, res, {
ddiff --git a/server/jobs/companies.js b/server/jobs/companies.js
nnew file mode 100644
iindex 0000000..a10c08e
---- /dev/null
++++ b/server/jobs/companies.js
@@@ -0,0 +1,75 @@
++'use strict';
++
++/**
++ * Companies: every company with open listings, and one page each (/companies, /companies/:slug). A company is its name
++ * as the boards write it, grouped by its slug, so "ACME" and "Acme" are one company; the name shown is the spelling
++ * most of its listings use.
++ *
++ * The slug is computed the same way here and in SQL (SLUG_SQL), so a page finds its rows without a stored column:
++ * lower case, common accented letters to their plain ones (ä → a, ß → s), every other run of characters to one dash.
++ * A name with no letter or digit left (only CJK, say) has no slug and no page; its listings stay in the search.
++ */
++
++// translate() pairs, one character each side: what the slug folds an accented letter to.
++const FOLD = [['äàáâãåā', 'a'], ['çćč', 'c'], ['ďđ', 'd'], ['ëèéêē', 'e'], ['ğ', 'g'], ['ïìíîī', 'i'], ['ł', 'l'], ['ñńň', 'n'],
++ ['öòóôõøō', 'o'], ['řŕ', 'r'], ['ßśšş', 's'], ['ťţ', 't'], ['üùúûůū', 'u'], ['ýÿ', 'y'], ['źżž', 'z'], ['æ', 'a'], ['œ', 'o']];
++const FROM = FOLD.map(([from]) => from).join('');
++const TO = FOLD.map(([from, to]) => to.repeat([...from].length)).join('');
++const FOLD_MAP = new Map([...FROM].map((c, i) => [c, TO[i]]));
++const MAX_SLUG = 80;
++const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?$/;
++
++/** A company name → its slug, or '' (the same answer as SLUG_SQL). */
++function slugOf(name) {
++ const folded = [...String(name == null ? '' : name).toLowerCase()].map((c) => FOLD_MAP.get(c) || c).join('');
++ return folded.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, MAX_SLUG).replace(/-+$/g, '');
++}
++
++/** The same slug in SQL; $1 and $2 are FROM and TO. */
++const SLUG_SQL = "rtrim(left(trim(both '-' from regexp_replace(translate(lower(company), $1, $2), '[^a-z0-9]+', '-', 'g')), 80), '-')";
++
++const num = (v) => (v == null ? 0 : Number(v));
++const TTL_MS = 10 * 60 * 1000;
++const lists = new WeakMap(); // store → { at, value }
++
++/** Every company with an open listing, most openings first: [{ slug, company, count, remote }]. Cached ten minutes. */
++async function list(s, { now = Date.now() } = {}) {
++ const hit = lists.get(s);
++ if (hit && now - hit.at < TTL_MS) return hit.value;
++ const rows = await s.db.many(
++ `SELECT ${SLUG_SQL} AS slug, mode() WITHIN GROUP (ORDER BY company) AS company, count(*) AS n, count(*) FILTER (WHERE remote) AS remote
++ FROM work_listings WHERE expired = false GROUP BY 1 HAVING ${SLUG_SQL} <> '' ORDER BY n DESC, slug ASC`, [FROM, TO]);
++ const value = rows.map((r) => ({ slug: r.slug, company: r.company, count: num(r.n), remote: num(r.remote) }));
++ lists.set(s, { at: now, value });
++ return value;
++}
++
++/**
++ * One company's open listings, newest first (up to `limit`), with where they are and which boards list them, or null.
++ * → { slug, company, count, remote, salaried, places: [{ place, count }], sources: [{ source, count }], rows }
++ */
++async function bySlug(s, slug, { limit = 60 } = {}) {
++ if (!SLUG_RE.test(String(slug || ''))) return null;
++ const where = `expired = false AND ${SLUG_SQL} = $3`;
++ const args = [FROM, TO, String(slug)];
++ const totals = await s.db.maybe(
++ `SELECT count(*) AS n, count(*) FILTER (WHERE remote) AS remote, count(*) FILTER (WHERE coalesce(salary, '') <> '') AS salaried,
++ mode() WITHIN GROUP (ORDER BY company) AS company FROM work_listings WHERE ${where}`, args);
++ if (!totals || !num(totals.n)) return null;
++ const [places, bySource, rows] = await Promise.all([
++ s.db.many(`SELECT trim(split_part(location, ',', 1)) AS p, count(*) AS n FROM work_listings WHERE ${where} AND coalesce(location, '') <> ''
++ GROUP BY 1 ORDER BY n DESC, p ASC LIMIT 12`, args),
++ s.db.many(`SELECT source, count(*) AS n FROM work_listings WHERE ${where} GROUP BY 1 ORDER BY n DESC, source ASC`, args),
++ s.db.many(`SELECT * FROM work_listings WHERE ${where} ORDER BY sort_at DESC, id DESC LIMIT ${Math.min(200, Math.max(1, Number(limit) || 60))}`, args),
++ ]);
++ return {
++ slug: String(slug), company: totals.company, count: num(totals.n), remote: num(totals.remote), salaried: num(totals.salaried),
++ places: places.filter((r) => r.p).map((r) => ({ place: r.p, count: num(r.n) })),
++ sources: bySource.map((r) => ({ source: r.source, count: num(r.n) })),
++ rows,
++ };
++}
++
++function _reset() { /* WeakMap entries go with their store */ }
++
++module.exports = { slugOf, list, bySlug, SLUG_SQL, FROM, TO, _reset };
ddiff --git a/test/companies.test.js b/test/companies.test.js
nnew file mode 100644
iindex 0000000..f607aa4
---- /dev/null
++++ b/test/companies.test.js
@@@ -0,0 +1,85 @@
++'use strict';
++/**
++ * Companies (server/jobs/companies.js, /companies, /companies/:slug): a company is grouped by its slug, which JS and SQL
++ * compute alike (accents folded, every other run of characters one dash); the index lists them by openings and finds
++ * one by name; a company's page shows its listings, places and boards, is noindex below three openings, and is a 404
++ * once nothing is open; the home, a listing and the sitemap link the pages.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const companies = require('../server/jobs/companies');
++
++(async () => {
++ await check('the slug: lower case, accents folded, one dash between words; nothing left means no page', () => {
++ assert.strictEqual(companies.slugOf('Skyward Cloud'), 'skyward-cloud');
++ assert.strictEqual(companies.slugOf('Müller & Söhne GmbH'), 'muller-sohne-gmbh');
++ assert.strictEqual(companies.slugOf(' Straße AG '), 'strase-ag');
++ assert.strictEqual(companies.slugOf('株式会社'), '');
++ assert.strictEqual(companies.slugOf('x'.repeat(200)).length, 80);
++ });
++
++ const t = await boot({ env: { WORK_INGEST_PAGES: '2' } });
++ try {
++ await t.ctx.ingest.runAll();
++ const s = t.ctx.s;
++ // Two spellings of one company and one with accents, added beside what the stand-in boards gave.
++ const now = new Date().toISOString();
++ const add = (id, company, title) => s.db.query(`INSERT INTO work_listings (id, source, source_id, url, title, company, location, remote, tags, job_type, salary, excerpt, search_text, posted_at, sort_at, fetched_at, first_seen_at, last_seen_at, expired)
++ VALUES ($1, 'remotive', $1, 'https://remotive.com/x', $2, $3, 'Berlin, Germany', true, '', null, null, 'x', lower($2), $4, $4, $4, $4, $4, false)`, [id, title, company, now]);
++ await add('job_00000000000000000000000001', 'ACME Widgets', 'Widget Engineer');
++ await add('job_00000000000000000000000002', 'Acme Widgets', 'Widget Tester');
++ await add('job_00000000000000000000000003', 'acme widgets', 'Widget Lead');
++ await add('job_00000000000000000000000004', 'Müller & Söhne GmbH', 'Schreiner');
++
++ await check('SQL and JS agree on every slug in the table', async () => {
++ const rows = await s.db.many(`SELECT company, ${companies.SLUG_SQL} AS slug FROM work_listings`, [companies.FROM, companies.TO]);
++ for (const r of rows) assert.strictEqual(r.slug, companies.slugOf(r.company), r.company);
++ });
++
++ await check('the index: every company by openings, one entry per slug, the most common spelling; found by name', async () => {
++ const all = await companies.list(s);
++ const acme = all.find((c) => c.slug === 'acme-widgets');
++ assert.deepStrictEqual([acme.count, acme.remote], [3, 3], 'three spellings, one company');
++ assert.ok(['ACME Widgets', 'Acme Widgets', 'acme widgets'].includes(acme.company));
++ assert.deepStrictEqual(all.map((c) => c.count), [...all.map((c) => c.count)].sort((a, b) => b - a), 'most openings first');
++ const page = await t.get('/companies');
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('href="/companies/acme-widgets"') && page.text.includes('href="/companies/muller-sohne-gmbh"'));
++ assert.doesNotMatch(page.text, /noindex/);
++ const found = await t.get('/companies?q=m%C3%BCller');
++ assert.ok(found.text.includes('/companies/muller-sohne-gmbh') && !found.text.includes('/companies/acme-widgets'));
++ assert.match(found.text, /noindex/, 'a search of the index is not indexed');
++ });
++
++ await check('a company page: its listings, places and boards; indexed from three openings, a 404 when nothing is open', async () => {
++ const acme = await t.get('/companies/acme-widgets');
++ assert.strictEqual(acme.status, 200);
++ assert.match(acme.text, /<h1>Jobs at (ACME|Acme|acme) Widgets<\/h1>/);
++ assert.ok(acme.text.includes('Widget Engineer') && acme.text.includes('Widget Lead'));
++ assert.ok(acme.text.includes('Berlin') && acme.text.includes('href="/sources#remotive"'));
++ assert.doesNotMatch(acme.text, /noindex/);
++ const muller = await t.get('/companies/muller-sohne-gmbh');
++ assert.strictEqual(muller.status, 200);
++ assert.match(muller.text, /noindex/, 'one opening is too thin to index');
++ assert.strictEqual((await t.get('/companies/no-such-company')).status, 404);
++ assert.strictEqual((await t.get('/companies/..%2Fetc')).status, 404);
++ await s.db.query("UPDATE work_listings SET expired = true WHERE company = 'Müller & Söhne GmbH'");
++ assert.strictEqual((await t.get('/companies/muller-sohne-gmbh')).status, 404, 'gone once nothing is open');
++ });
++
++ await check('the home, a listing and the sitemap link the company pages', async () => {
++ const home = await t.get('/');
++ assert.match(home.text, /href="\/companies\/[a-z0-9-]+"/);
++ assert.ok(home.text.includes('href="/companies"'));
++ const job = await t.get(`/jobs/job_${'0'.repeat(25)}1`);
++ assert.strictEqual(job.status, 200);
++ assert.ok(job.text.includes('href="/companies/acme-widgets"'), 'a listing links its company');
++ const map = await t.get('/sitemap.xml');
++ assert.ok(map.text.includes('/companies</loc>') && map.text.includes('/companies/acme-widgets</loc>'));
++ assert.ok(!map.text.includes('/companies/muller-sohne-gmbh'));
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();
ddiff --git a/test/jobs.test.js b/test/jobs.test.js
iindex 647b2ad..eb3cb5b 100644
---- a/test/jobs.test.js
++++ b/test/jobs.test.js
@@@ -229,7 +229,7 @@ const first = async (t, path) => (await t.get(path)).json();
}
assert.match(r.text, /no paywall/i, '"how it works" does not say what is behind a paywall (nothing)');
for (const h of ['Browse by field', 'Jobs by city', 'Companies with the most openings', 'Questions people ask']) assert.ok(r.text.includes(h), `the home has "${h}"`);
-- assert.match(r.text, /<li><a href="\/jobs\?q=[^"]+"><b>[^<]+<\/b><span>\d+ open listings?<\/span>/, 'a company card opens a search for it, with its count');
++ assert.match(r.text, /<li><a href="\/companies\/[a-z0-9-]+"><b>[^<]+<\/b><span>\d+ open listings?<\/span>/, 'a company card opens its company page, with its count');
assert.match(r.text, /"@type":"ItemList"/, 'the latest remote jobs are an ItemList');
});