openvibers/openvibe.work Commits
Work: tell search engines about new listings (IndexNow) (#43)
Changes
@@@ -48,6 +48,10 @@ WORK_EVENTS_URL=# optional: defaults to true in production# COOKIE_SECURE=true++# IndexNow: with a key (8–128 letters and digits), each ingest run's new listing pages and the sitemap are sent to++# search engines in one batch, and the key is served at /<key>.txt. Unset: off.++#INDEXNOW_KEY=++# The job-board ingest (server/ingest/). Off unless WORK_INGEST=on: a development run and the test suite read no board.# Set it on the host, or no listing ever arrives. Each source is read at most every WORK_INGEST_INTERVAL_HOURS (6),# which is exactly the four calls a day Remotive's terms allow.@@@ -39,7 +39,7 @@ republished: Work stores a short plain-text excerpt and sends the reader to the| Actor | [server/actor.js](server/actor.js) | `POST /api/v1/tasks` on the person's OpenVibe.Actor agent with Work's own service token || API | [server/http/api.js](server/http/api.js) | `/api/v1`: jobs, one job, the sources, saved searches, applications; problem+json errors || Pages | [server/http/pages.js](server/http/pages.js), [server/render/](server/render/) | `/`, `/jobs`, `/jobs/:id`, `/saved`, `/applications`, `/applications/:id`, `/sources`, `/docs`, `/updates` — server-rendered, no JavaScript needed |--| Discovery | [server/http/discovery.js](server/http/discovery.js) | `robots.txt`, `sitemap.xml` (the newest listings too), `llms.txt`, `llms-full.txt`, JSON-LD |++| Discovery | [server/http/discovery.js](server/http/discovery.js) | `robots.txt`, `sitemap.xml` (the newest listings too), `llms.txt`, `llms-full.txt`, JSON-LD; with `INDEXNOW_KEY`, each ingest run's new listings are sent to IndexNow || Limits | [server/http/caller-limits.js](server/http/caller-limits.js), [deploy/nginx/](deploy/nginx/) | Per-caller limits at the API (the search and the saved-search writes have their own) and per-address limits at nginx || Health | [server/observability.js](server/observability.js) | `/api/health`, a truthful `/api/ready` (the database is required) and Prometheus metrics on loopback only || Database | [server/db.js](server/db.js), [migrations/](migrations/) | PostgreSQL through `openvibe-sdk/db`; `NNNN_*.sql` applied at boot; PGlite in development |@@@ -31,6 +31,7 @@ const { createPageRoutes } = require('./http/pages');const { createServiceReadiness } = require('./observability');const { createCallerLimits } = require('./http/caller-limits');const { createIngest } = require('./ingest');++const { createIndexNow } = require('openvibe-shared/indexnow');const { createSearchIndex } = require('./search-index');const accountDataLib = require('./identity/account-data');const { createNetworkSender } = require('openvibe-sdk/account-data');@@@ -74,7 +75,16 @@ async function createApp(opts = {}) {// Listing pages in OpenVibe.Search (./search-index.js): swept after every ingest run, and on its own timer once// server/index.js starts it. The outbox relay is off until the events URL and the OAuth client secret are set.ctx.search = opts.search || createSearchIndex({ config, s, log });-- ctx.ingest = createIngest({ config, s, fetchImpl, log, afterRun: () => ctx.search.afterIngest() });++ // IndexNow (openvibe-shared/indexnow): off without INDEXNOW_KEY; a malformed key is a configuration mistake, not++ // a reason to leave the site down, so it warns and runs with IndexNow off.++ ctx.indexnow = opts.indexnow || (() => {++ const warn = (...a) => (log.warn || console.warn)(...a);++ try { return createIndexNow({ host: config.baseUrl, key: config.indexnow.key, fetch: fetchImpl, log: warn }); } catch (err) {++ warn(`[Work] ${err.message} — IndexNow is off until INDEXNOW_KEY is fixed`);++ return createIndexNow({ host: config.baseUrl, key: '', fetch: fetchImpl, log: warn });++ }++ })();++ ctx.ingest = createIngest({ config, s, fetchImpl, log, afterRun: () => ctx.search.afterIngest(), indexnow: ctx.indexnow });// The person's OpenVibe.Actor agent (server/actor.js) and the per-person daily cap on asking it. A test injects// a stand-in client and clock through opts.actor / opts.assistLimitsNow, so the assist route never calls out.@@@ -145,6 +155,8 @@ async function createApp(opts = {}) {// ── Static assets (content-hashed ?v= → immutable) ──────app.use('/shared', require('openvibe-shared/serve').handler());++ // GET /<key>.txt — the IndexNow key file, only when a key is configured.++ if (ctx.indexnow.enabled) app.use(ctx.indexnow.keyFile);app.use(express.static(PUBLIC_DIR, {index: false, redirect: false,setHeaders(res, filePath) {@@@ -89,6 +89,9 @@ function load(env = process.env) {// The background ingest (server/ingest/): off unless WORK_INGEST=on, so a test and a development run// never call a job board. Once on, each source is fetched at most every intervalMs (Remotive's terms// allow about four calls a day: six hours is exactly four), and listings not seen for retentionDays go.++ // IndexNow (openvibe-shared/indexnow): with a key, search engines hear about each listing an ingest run++ // adds (and the sitemap) in one batched POST; unset → off.++ indexnow: { key: String(env.INDEXNOW_KEY || '').trim() },ingest: {enabled: String(env.WORK_INGEST || '').toLowerCase() === 'on',intervalMs: Math.max(1, num(env.WORK_INGEST_INTERVAL_HOURS, 6)) * 3_600_000,@@@ -36,7 +36,7 @@ async function fetchJson(url, { fetchImpl, timeoutMs, userAgent }) {const TICK_MS = 3_600_000;--function createIngest({ config, s, fetchImpl = globalThis.fetch, log = console, afterRun = null }) {++function createIngest({ config, s, fetchImpl = globalThis.fetch, log = console, afterRun = null, indexnow = null }) {const timers = new Map();let running = false;@@@ -63,6 +63,10 @@ function createIngest({ config, s, fetchImpl = globalThis.fetch, log = console,listings = out.seen;await store.finishFetch(s, source.id, { ok: true, listings, error: null, finishedAt: s.iso() });log.log(`[Ingest] ${source.name}: ${listings} listings (${out.added} new, ${out.updated} updated)`);++ // Search engines hear about the new listing pages (and the sitemap) in one batch (openvibe-shared/indexnow).++ if (indexnow && out.addedIds.length) {++ indexnow.pingSoon([...out.addedIds.map((id) => `${config.baseUrl}/jobs/${encodeURIComponent(id)}`), `${config.baseUrl}/sitemap.xml`]);++ }return { ok: true, listings, added: out.added, updated: out.updated, rows };} catch (err) {const detail = (err && err.message) || String(err);@@@ -170,11 +170,12 @@ function newCountSql() {// ── ingest writes ───────────────────────────────────────────/*** Upsert one source's listings. A listing the board still shows is updated in place and its last_seen_at moves; one-- * that reappears after expiring comes back (expired = false). Returns { seen, added, updated }.++ * that reappears after expiring comes back (expired = false). Returns { seen, added, updated, addedIds }.*/async function upsertListings(s, rows, { sourceId, fetchedAt }) {let added = 0;let updated = 0;++ const addedIds = [];await s.tx(async () => {for (const row of rows) {const out = await s.db.maybe(@@@ -188,10 +189,10 @@ async function upsertListings(s, rows, { sourceId, fetchedAt }) {expired = falseRETURNING (xmax = 0) AS inserted`,[row.id, row.source, row.source_id, row.url, row.title, row.company, row.location, row.remote, row.tags, row.job_type, row.salary, row.excerpt, row.search_text, row.posted_at, row.sort_at, fetchedAt]);-- if (out && out.inserted) added++; else updated++;++ if (out && out.inserted) { added++; addedIds.push(row.id); } else updated++;}});-- return { seen: rows.length, added, updated };++ return { seen: rows.length, added, updated, addedIds };}/** A source's fetch record: started now, finished when it finished, with what happened. */@@@ -89,5 +89,23 @@ const PRIVATE = ['/auth/', '/api/'];assert.strictEqual(page.url, 'https://openvibe.work/');});} finally { await t.close(); }++++ // IndexNow's key file: served at /<key>.txt only when INDEXNOW_KEY is set (a computed stand-in key here).++ const key = ['in', 'dex', 'now'].join('') + '0123456789';++ const k = await boot({ env: { INDEXNOW_KEY: key } });++ try {++ await check('IndexNow: /<key>.txt answers the key as text/plain when a key is configured', async () => {++ const r = await k.get(`/${key}.txt`);++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type'), /^text\/plain/);++ assert.strictEqual(r.text, key);++ });++ } finally { await k.close(); }++ const off = await boot();++ try {++ await check('IndexNow: without a key there is no key file', async () => {++ assert.strictEqual((await off.get(`/${key}.txt`)).status, 404);++ });++ } finally { await off.close(); }done();})();@@@ -45,7 +45,7 @@ async function boot(opts = {}) {const { createStore } = require('../../server/db');const testdb = await require('./db').testDb();const store = createStore(testdb.db, { now: opts.now });-- const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, actor: opts.actor, assistLimitsNow: opts.assistLimitsNow });++ const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, actor: opts.actor, assistLimitsNow: opts.assistLimitsNow, indexnow: opts.indexnow });await built.ctx.keys.ensure();const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;@@@ -13,7 +13,9 @@ const DAY = 86_400_000;(async () => {const clock = { t: Date.parse('2026-10-01T06:00:00Z') };-- const t = await boot({ now: () => clock.t, env: { WORK_INGEST_PAGES: '2' } });++ const pinged = [];++ const indexnow = { enabled: false, pingSoon: (urls) => { pinged.push(...urls); return urls.length; } };++ const t = await boot({ now: () => clock.t, env: { WORK_INGEST_PAGES: '2' }, indexnow });const ingest = t.ctx.ingest;const s = t.ctx.s;@@@ -44,6 +46,12 @@ const DAY = 86_400_000;const all = (await t.get('/api/v1/jobs?limit=100')).json();assert.strictEqual(all.count, 8);assert.ok(!JSON.stringify(all.jobs).includes('personal use'), 'the legal notice became a listing');++++ // Search engines hear about each new listing page and the sitemap (IndexNow), absolute and on this host.++ const jobUrls = pinged.filter((u) => u !== 'https://openvibe.work/sitemap.xml');++ assert.strictEqual(jobUrls.length, 8, pinged.join(' '));++ assert.deepStrictEqual(jobUrls.sort(), all.jobs.map((j) => `https://openvibe.work/jobs/${j.id}`).sort());++ assert.ok(pinged.includes('https://openvibe.work/sitemap.xml'));});await check('every request used this service\'s User-Agent, a JSON Accept, and only the configured hosts', async () => {@@@ -98,7 +106,9 @@ const DAY = 86_400_000;// The board's own text changed under the same id, as a board edits a listing.t.boards.fixtures.remoteok[1].position = 'DevOps Engineer (Platform)';const remoteok = (await ingest.runAll()).find((o) => o.source === 'remoteok');++ pinged.length = 0;assert.deepStrictEqual([remoteok.ok, remoteok.listings, remoteok.added, remoteok.updated], [true, 2, 0, 2]);++ assert.deepStrictEqual(pinged, [], 'a listing read again is not new: no IndexNow ping');const after = (await t.get('/api/v1/jobs?q=DevOps')).json().jobs[0];assert.strictEqual(after.id, before.id, 'the id must not change when a board is read again');assert.strictEqual(after.title, 'DevOps Engineer (Platform)');
@@ -48,6 +48,10 @@ WORK_EVENTS_URL=
@@ -48,6 +48,10 @@ WORK_EVENTS_URL=
# optional: defaults to true in production
# optional: defaults to true in production
# COOKIE_SECURE=true
# COOKIE_SECURE=true
+# IndexNow: with a key (8–128 letters and digits), each ingest run's new listing pages and the sitemap are sent to
+# search engines in one batch, and the key is served at /<key>.txt. Unset: off.
+#INDEXNOW_KEY=
+
# The job-board ingest (server/ingest/). Off unless WORK_INGEST=on: a development run and the test suite read no board.
# The job-board ingest (server/ingest/). Off unless WORK_INGEST=on: a development run and the test suite read no board.
# Set it on the host, or no listing ever arrives. Each source is read at most every WORK_INGEST_INTERVAL_HOURS (6),
# Set it on the host, or no listing ever arrives. Each source is read at most every WORK_INGEST_INTERVAL_HOURS (6),
# which is exactly the four calls a day Remotive's terms allow.
# which is exactly the four calls a day Remotive's terms allow.
@@ -39,7 +39,7 @@ republished: Work stores a short plain-text excerpt and sends the reader to the
@@ -39,7 +39,7 @@ republished: Work stores a short plain-text excerpt and sends the reader to the
| Actor | [server/actor.js](server/actor.js) | `POST /api/v1/tasks` on the person's OpenVibe.Actor agent with Work's own service token |
| Actor | [server/actor.js](server/actor.js) | `POST /api/v1/tasks` on the person's OpenVibe.Actor agent with Work's own service token |
| API | [server/http/api.js](server/http/api.js) | `/api/v1`: jobs, one job, the sources, saved searches, applications; problem+json errors |
| API | [server/http/api.js](server/http/api.js) | `/api/v1`: jobs, one job, the sources, saved searches, applications; problem+json errors |
| Pages | [server/http/pages.js](server/http/pages.js), [server/render/](server/render/) | `/`, `/jobs`, `/jobs/:id`, `/saved`, `/applications`, `/applications/:id`, `/sources`, `/docs`, `/updates` — server-rendered, no JavaScript needed |
| Pages | [server/http/pages.js](server/http/pages.js), [server/render/](server/render/) | `/`, `/jobs`, `/jobs/:id`, `/saved`, `/applications`, `/applications/:id`, `/sources`, `/docs`, `/updates` — server-rendered, no JavaScript needed |
-| Discovery | [server/http/discovery.js](server/http/discovery.js) | `robots.txt`, `sitemap.xml` (the newest listings too), `llms.txt`, `llms-full.txt`, JSON-LD |
+| Discovery | [server/http/discovery.js](server/http/discovery.js) | `robots.txt`, `sitemap.xml` (the newest listings too), `llms.txt`, `llms-full.txt`, JSON-LD; with `INDEXNOW_KEY`, each ingest run's new listings are sent to IndexNow |
| Limits | [server/http/caller-limits.js](server/http/caller-limits.js), [deploy/nginx/](deploy/nginx/) | Per-caller limits at the API (the search and the saved-search writes have their own) and per-address limits at nginx |
| Limits | [server/http/caller-limits.js](server/http/caller-limits.js), [deploy/nginx/](deploy/nginx/) | Per-caller limits at the API (the search and the saved-search writes have their own) and per-address limits at nginx |
| Health | [server/observability.js](server/observability.js) | `/api/health`, a truthful `/api/ready` (the database is required) and Prometheus metrics on loopback only |
| Health | [server/observability.js](server/observability.js) | `/api/health`, a truthful `/api/ready` (the database is required) and Prometheus metrics on loopback only |
| Database | [server/db.js](server/db.js), [migrations/](migrations/) | PostgreSQL through `openvibe-sdk/db`; `NNNN_*.sql` applied at boot; PGlite in development |
| Database | [server/db.js](server/db.js), [migrations/](migrations/) | PostgreSQL through `openvibe-sdk/db`; `NNNN_*.sql` applied at boot; PGlite in development |
@@ -31,6 +31,7 @@ const { createPageRoutes } = require('./http/pages');
@@ -31,6 +31,7 @@ const { createPageRoutes } = require('./http/pages');
const { createServiceReadiness } = require('./observability');
const { createServiceReadiness } = require('./observability');
const { createCallerLimits } = require('./http/caller-limits');
const { createCallerLimits } = require('./http/caller-limits');
const { createIngest } = require('./ingest');
const { createIngest } = require('./ingest');
+const { createIndexNow } = require('openvibe-shared/indexnow');
const { createSearchIndex } = require('./search-index');
const { createSearchIndex } = require('./search-index');
const accountDataLib = require('./identity/account-data');
const accountDataLib = require('./identity/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
@@ -74,7 +75,16 @@ async function createApp(opts = {}) {
@@ -74,7 +75,16 @@ async function createApp(opts = {}) {
// Listing pages in OpenVibe.Search (./search-index.js): swept after every ingest run, and on its own timer once
// Listing pages in OpenVibe.Search (./search-index.js): swept after every ingest run, and on its own timer once
// server/index.js starts it. The outbox relay is off until the events URL and the OAuth client secret are set.
// server/index.js starts it. The outbox relay is off until the events URL and the OAuth client secret are set.
ctx.search = opts.search || createSearchIndex({ config, s, log });
ctx.search = opts.search || createSearchIndex({ config, s, log });
- ctx.ingest = createIngest({ config, s, fetchImpl, log, afterRun: () => ctx.search.afterIngest() });
+ // IndexNow (openvibe-shared/indexnow): off without INDEXNOW_KEY; a malformed key is a configuration mistake, not
+ // a reason to leave the site down, so it warns and runs with IndexNow off.
+ ctx.indexnow = opts.indexnow || (() => {
+ const warn = (...a) => (log.warn || console.warn)(...a);
+ try { return createIndexNow({ host: config.baseUrl, key: config.indexnow.key, fetch: fetchImpl, log: warn }); } catch (err) {
+ warn(`[Work] ${err.message} — IndexNow is off until INDEXNOW_KEY is fixed`);
+ return createIndexNow({ host: config.baseUrl, key: '', fetch: fetchImpl, log: warn });
+ }
+ })();
+ ctx.ingest = createIngest({ config, s, fetchImpl, log, afterRun: () => ctx.search.afterIngest(), indexnow: ctx.indexnow });
// The person's OpenVibe.Actor agent (server/actor.js) and the per-person daily cap on asking it. A test injects
// The person's OpenVibe.Actor agent (server/actor.js) and the per-person daily cap on asking it. A test injects
// a stand-in client and clock through opts.actor / opts.assistLimitsNow, so the assist route never calls out.
// a stand-in client and clock through opts.actor / opts.assistLimitsNow, so the assist route never calls out.
@@ -145,6 +155,8 @@ async function createApp(opts = {}) {
@@ -145,6 +155,8 @@ async function createApp(opts = {}) {
// ── Static assets (content-hashed ?v= → immutable) ──────
// ── Static assets (content-hashed ?v= → immutable) ──────
app.use('/shared', require('openvibe-shared/serve').handler());
app.use('/shared', require('openvibe-shared/serve').handler());
+ // GET /<key>.txt — the IndexNow key file, only when a key is configured.
+ if (ctx.indexnow.enabled) app.use(ctx.indexnow.keyFile);
app.use(express.static(PUBLIC_DIR, {
app.use(express.static(PUBLIC_DIR, {
index: false, redirect: false,
index: false, redirect: false,
setHeaders(res, filePath) {
setHeaders(res, filePath) {
@@ -89,6 +89,9 @@ function load(env = process.env) {
@@ -89,6 +89,9 @@ function load(env = process.env) {
// The background ingest (server/ingest/): off unless WORK_INGEST=on, so a test and a development run
// The background ingest (server/ingest/): off unless WORK_INGEST=on, so a test and a development run
// never call a job board. Once on, each source is fetched at most every intervalMs (Remotive's terms
// never call a job board. Once on, each source is fetched at most every intervalMs (Remotive's terms
// allow about four calls a day: six hours is exactly four), and listings not seen for retentionDays go.
// allow about four calls a day: six hours is exactly four), and listings not seen for retentionDays go.
+ // IndexNow (openvibe-shared/indexnow): with a key, search engines hear about each listing an ingest run
+ // adds (and the sitemap) in one batched POST; unset → off.
+ indexnow: { key: String(env.INDEXNOW_KEY || '').trim() },
ingest: {
ingest: {
enabled: String(env.WORK_INGEST || '').toLowerCase() === 'on',
enabled: String(env.WORK_INGEST || '').toLowerCase() === 'on',
intervalMs: Math.max(1, num(env.WORK_INGEST_INTERVAL_HOURS, 6)) * 3_600_000,
intervalMs: Math.max(1, num(env.WORK_INGEST_INTERVAL_HOURS, 6)) * 3_600_000,
@@ -36,7 +36,7 @@ async function fetchJson(url, { fetchImpl, timeoutMs, userAgent }) {
@@ -36,7 +36,7 @@ async function fetchJson(url, { fetchImpl, timeoutMs, userAgent }) {
const TICK_MS = 3_600_000;
const TICK_MS = 3_600_000;
-function createIngest({ config, s, fetchImpl = globalThis.fetch, log = console, afterRun = null }) {
+function createIngest({ config, s, fetchImpl = globalThis.fetch, log = console, afterRun = null, indexnow = null }) {
const timers = new Map();
const timers = new Map();
let running = false;
let running = false;
@@ -63,6 +63,10 @@ function createIngest({ config, s, fetchImpl = globalThis.fetch, log = console,
@@ -63,6 +63,10 @@ function createIngest({ config, s, fetchImpl = globalThis.fetch, log = console,
listings = out.seen;
listings = out.seen;
await store.finishFetch(s, source.id, { ok: true, listings, error: null, finishedAt: s.iso() });
await store.finishFetch(s, source.id, { ok: true, listings, error: null, finishedAt: s.iso() });
log.log(`[Ingest] ${source.name}: ${listings} listings (${out.added} new, ${out.updated} updated)`);
log.log(`[Ingest] ${source.name}: ${listings} listings (${out.added} new, ${out.updated} updated)`);
+ // Search engines hear about the new listing pages (and the sitemap) in one batch (openvibe-shared/indexnow).
+ if (indexnow && out.addedIds.length) {
+ indexnow.pingSoon([...out.addedIds.map((id) => `${config.baseUrl}/jobs/${encodeURIComponent(id)}`), `${config.baseUrl}/sitemap.xml`]);
+ }
return { ok: true, listings, added: out.added, updated: out.updated, rows };
return { ok: true, listings, added: out.added, updated: out.updated, rows };
} catch (err) {
} catch (err) {
const detail = (err && err.message) || String(err);
const detail = (err && err.message) || String(err);
@@ -170,11 +170,12 @@ function newCountSql() {
@@ -170,11 +170,12 @@ function newCountSql() {
// ── ingest writes ───────────────────────────────────────────
// ── ingest writes ───────────────────────────────────────────
/**
/**
* Upsert one source's listings. A listing the board still shows is updated in place and its last_seen_at moves; one
* Upsert one source's listings. A listing the board still shows is updated in place and its last_seen_at moves; one
- * that reappears after expiring comes back (expired = false). Returns { seen, added, updated }.
+ * that reappears after expiring comes back (expired = false). Returns { seen, added, updated, addedIds }.
*/
*/
async function upsertListings(s, rows, { sourceId, fetchedAt }) {
async function upsertListings(s, rows, { sourceId, fetchedAt }) {
let added = 0;
let added = 0;
let updated = 0;
let updated = 0;
+ const addedIds = [];
await s.tx(async () => {
await s.tx(async () => {
for (const row of rows) {
for (const row of rows) {
const out = await s.db.maybe(
const out = await s.db.maybe(
@@ -188,10 +189,10 @@ async function upsertListings(s, rows, { sourceId, fetchedAt }) {
@@ -188,10 +189,10 @@ async function upsertListings(s, rows, { sourceId, fetchedAt }) {
expired = false
expired = false
RETURNING (xmax = 0) AS inserted`,
RETURNING (xmax = 0) AS inserted`,
[row.id, row.source, row.source_id, row.url, row.title, row.company, row.location, row.remote, row.tags, row.job_type, row.salary, row.excerpt, row.search_text, row.posted_at, row.sort_at, fetchedAt]);
[row.id, row.source, row.source_id, row.url, row.title, row.company, row.location, row.remote, row.tags, row.job_type, row.salary, row.excerpt, row.search_text, row.posted_at, row.sort_at, fetchedAt]);
- if (out && out.inserted) added++; else updated++;
+ if (out && out.inserted) { added++; addedIds.push(row.id); } else updated++;
}
}
});
});
- return { seen: rows.length, added, updated };
+ return { seen: rows.length, added, updated, addedIds };
}
}
/** A source's fetch record: started now, finished when it finished, with what happened. */
/** A source's fetch record: started now, finished when it finished, with what happened. */
@@ -89,5 +89,23 @@ const PRIVATE = ['/auth/', '/api/'];
@@ -89,5 +89,23 @@ const PRIVATE = ['/auth/', '/api/'];
assert.strictEqual(page.url, 'https://openvibe.work/');
assert.strictEqual(page.url, 'https://openvibe.work/');
});
});
} finally { await t.close(); }
} finally { await t.close(); }
+
+ // IndexNow's key file: served at /<key>.txt only when INDEXNOW_KEY is set (a computed stand-in key here).
+ const key = ['in', 'dex', 'now'].join('') + '0123456789';
+ const k = await boot({ env: { INDEXNOW_KEY: key } });
+ try {
+ await check('IndexNow: /<key>.txt answers the key as text/plain when a key is configured', async () => {
+ const r = await k.get(`/${key}.txt`);
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type'), /^text\/plain/);
+ assert.strictEqual(r.text, key);
+ });
+ } finally { await k.close(); }
+ const off = await boot();
+ try {
+ await check('IndexNow: without a key there is no key file', async () => {
+ assert.strictEqual((await off.get(`/${key}.txt`)).status, 404);
+ });
+ } finally { await off.close(); }
done();
done();
})();
})();
@@ -45,7 +45,7 @@ async function boot(opts = {}) {
@@ -45,7 +45,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
const store = createStore(testdb.db, { now: opts.now });
- const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, actor: opts.actor, assistLimitsNow: opts.assistLimitsNow });
+ const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, actor: opts.actor, assistLimitsNow: opts.assistLimitsNow, indexnow: opts.indexnow });
await built.ctx.keys.ensure();
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
@@ -13,7 +13,9 @@ const DAY = 86_400_000;
@@ -13,7 +13,9 @@ const DAY = 86_400_000;
(async () => {
(async () => {
const clock = { t: Date.parse('2026-10-01T06:00:00Z') };
const clock = { t: Date.parse('2026-10-01T06:00:00Z') };
- const t = await boot({ now: () => clock.t, env: { WORK_INGEST_PAGES: '2' } });
+ const pinged = [];
+ const indexnow = { enabled: false, pingSoon: (urls) => { pinged.push(...urls); return urls.length; } };
+ const t = await boot({ now: () => clock.t, env: { WORK_INGEST_PAGES: '2' }, indexnow });
const ingest = t.ctx.ingest;
const ingest = t.ctx.ingest;
const s = t.ctx.s;
const s = t.ctx.s;
@@ -44,6 +46,12 @@ const DAY = 86_400_000;
@@ -44,6 +46,12 @@ const DAY = 86_400_000;
const all = (await t.get('/api/v1/jobs?limit=100')).json();
const all = (await t.get('/api/v1/jobs?limit=100')).json();
assert.strictEqual(all.count, 8);
assert.strictEqual(all.count, 8);
assert.ok(!JSON.stringify(all.jobs).includes('personal use'), 'the legal notice became a listing');
assert.ok(!JSON.stringify(all.jobs).includes('personal use'), 'the legal notice became a listing');
+
+ // Search engines hear about each new listing page and the sitemap (IndexNow), absolute and on this host.
+ const jobUrls = pinged.filter((u) => u !== 'https://openvibe.work/sitemap.xml');
+ assert.strictEqual(jobUrls.length, 8, pinged.join(' '));
+ assert.deepStrictEqual(jobUrls.sort(), all.jobs.map((j) => `https://openvibe.work/jobs/${j.id}`).sort());
+ assert.ok(pinged.includes('https://openvibe.work/sitemap.xml'));
});
});
await check('every request used this service\'s User-Agent, a JSON Accept, and only the configured hosts', async () => {
await check('every request used this service\'s User-Agent, a JSON Accept, and only the configured hosts', async () => {
@@ -98,7 +106,9 @@ const DAY = 86_400_000;
@@ -98,7 +106,9 @@ const DAY = 86_400_000;
// The board's own text changed under the same id, as a board edits a listing.
// The board's own text changed under the same id, as a board edits a listing.
t.boards.fixtures.remoteok[1].position = 'DevOps Engineer (Platform)';
t.boards.fixtures.remoteok[1].position = 'DevOps Engineer (Platform)';
const remoteok = (await ingest.runAll()).find((o) => o.source === 'remoteok');
const remoteok = (await ingest.runAll()).find((o) => o.source === 'remoteok');
+ pinged.length = 0;
assert.deepStrictEqual([remoteok.ok, remoteok.listings, remoteok.added, remoteok.updated], [true, 2, 0, 2]);
assert.deepStrictEqual([remoteok.ok, remoteok.listings, remoteok.added, remoteok.updated], [true, 2, 0, 2]);
+ assert.deepStrictEqual(pinged, [], 'a listing read again is not new: no IndexNow ping');
const after = (await t.get('/api/v1/jobs?q=DevOps')).json().jobs[0];
const after = (await t.get('/api/v1/jobs?q=DevOps')).json().jobs[0];
assert.strictEqual(after.id, before.id, 'the id must not change when a board is read again');
assert.strictEqual(after.id, before.id, 'the id must not change when a board is read again');
assert.strictEqual(after.title, 'DevOps Engineer (Platform)');
assert.strictEqual(after.title, 'DevOps Engineer (Platform)');