openvibers/openvibe.work Commits
Company pages: /companies by openings and /companies/:slug with the company's listings, places and boards (#63)
Changes
@@@ -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));});@@@ -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, {nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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();++})();@@@ -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');});
@@ -15,6 +15,7 @@ const seo = require('openvibe-shared/seo');
@@ -15,6 +15,7 @@ const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const store = require('../jobs/store');
const store = require('../jobs/store');
+const companies = require('../jobs/companies');
const sources = require('../jobs/sources');
const sources = require('../jobs/sources');
const SITE_NAME = 'OpenVibe.Work';
const SITE_NAME = 'OpenVibe.Work';
@@ -25,6 +26,7 @@ const SITEMAP_JOBS = 1000;
@@ -25,6 +26,7 @@ const SITEMAP_JOBS = 1000;
const PAGE_TEXT = {
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.'],
'/': ['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.'],
'/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.'],
'/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.'],
'/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.'],
'/updates': ['What shipped on OpenVibe.Work', 'This site\'s update log, from the network changelog feed.'],
@@ -50,6 +52,7 @@ function homeJsonLd(config) {
@@ -50,6 +52,7 @@ function homeJsonLd(config) {
const publicPages = () => [
const publicPages = () => [
{ path: '/', changefreq: 'daily', priority: 1.0 },
{ path: '/', changefreq: 'daily', priority: 1.0 },
{ path: '/jobs', changefreq: 'hourly', priority: 0.9 },
{ path: '/jobs', changefreq: 'hourly', priority: 0.9 },
+ { path: '/companies', changefreq: 'daily', priority: 0.8 },
{ path: '/sources', changefreq: 'weekly', priority: 0.6 },
{ path: '/sources', changefreq: 'weekly', priority: 0.6 },
{ path: '/docs', changefreq: 'monthly', priority: 0.5 },
{ path: '/docs', changefreq: 'monthly', priority: 0.5 },
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
@@ -111,6 +114,8 @@ function createDiscoveryRoutes(ctx) {
@@ -111,6 +114,8 @@ function createDiscoveryRoutes(ctx) {
const urls = publicPages().map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));
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
// 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.
// 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 });
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));
res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
});
});
@@ -1,7 +1,8 @@
@@ -1,7 +1,8 @@
'use strict';
'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,
* (/sources) and the update log (/updates). Crawl artifacts (robots.txt, sitemap.xml, llms.txt, llms-full.txt,
* JSON-LD) are http/discovery.js.
* JSON-LD) are http/discovery.js.
*
*
@@ -20,6 +21,7 @@ const { html, raw, table, notice, time } = require('../render/html');
@@ -20,6 +21,7 @@ const { html, raw, table, notice, time } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const listing = require('../render/listing');
const listing = require('../render/listing');
const store = require('../jobs/store');
const store = require('../jobs/store');
+const companies = require('../jobs/companies');
const sources = require('../jobs/sources');
const sources = require('../jobs/sources');
const applications = require('../applications');
const applications = require('../applications');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
@@ -163,8 +165,9 @@ ${salaried.length ? html`<section class="sc-sec" aria-labelledby="h-pay"><h2 id=
@@ -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>
<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>` : ''}
${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>
${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>
<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>
<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) => {
${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</
@@ -280,7 +283,7 @@ ${nextUrl ? html`<a class="sc-btn sc-primary" href="${nextUrl}">Older listings</
jsonLd: [listing.jobPosting(row, { siteUrl: config.baseUrl })],
jsonLd: [listing.jobPosting(row, { siteUrl: config.baseUrl })],
body: html`<article class="job">
body: html`<article class="job">
<h1>${l.title}</h1>
<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.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>` : ''}
${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>
<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>` : ''}
@@ -326,6 +329,56 @@ ${l.posted_at ? html`<dt>Posted</dt><dd>${time(l.posted_at)}</dd>` : ''}
return bits.length ? bits.join(' ') : 'everything';
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) => {
r.get('/saved', async (req, res) => {
if (!signedIn(req)) {
if (!signedIn(req)) {
return page(req, res, {
return page(req, res, {
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -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();
+})();
@@ -229,7 +229,7 @@ const first = async (t, path) => (await t.get(path)).json();
@@ -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)');
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}"`);
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');
assert.match(r.text, /"@type":"ItemList"/, 'the latest remote jobs are an ItemList');
});
});