nuxt-ai-ready 0.7.16 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/module.json +1 -1
- package/dist/module.mjs +60 -8
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +0 -2
- package/dist/runtime/server/db/queries.d.ts +5 -0
- package/dist/runtime/server/db/queries.js +26 -0
- package/dist/runtime/server/db/shared.d.ts +5 -1
- package/dist/runtime/server/db/shared.js +4 -31
- package/dist/runtime/server/routes/__ai-ready/indexnow.post.js +1 -1
- package/dist/runtime/server/routes/__ai-ready-debug.get.d.ts +13 -1
- package/dist/runtime/server/routes/__ai-ready-debug.get.js +50 -5
- package/dist/runtime/server/utils/checkStale.d.ts +20 -0
- package/dist/runtime/server/utils/checkStale.js +100 -0
- package/dist/runtime/server/utils/indexnow-shared.d.ts +57 -0
- package/dist/runtime/server/utils/indexnow-shared.js +77 -0
- package/dist/runtime/server/utils/indexnow.d.ts +7 -1
- package/dist/runtime/server/utils/indexnow.js +51 -56
- package/dist/runtime/server/utils/runCron.d.ts +6 -0
- package/dist/runtime/server/utils/runCron.js +71 -1
- package/package.json +1 -1
- package/dist/runtime/server/plugins/sitemap-seeder.d.ts +0 -2
- package/dist/runtime/server/plugins/sitemap-seeder.js +0 -58
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { parseSitemapXml } from '@nuxtjs/sitemap/utils';
|
|
|
11
11
|
import { colorize } from 'consola/utils';
|
|
12
12
|
import { withBase } from 'ufo';
|
|
13
13
|
import { createAdapter, initSchema, computeContentHash, insertPage, queryAllPages, exportDbDump } from '../dist/runtime/server/db/shared.js';
|
|
14
|
+
import { comparePageHashes, submitToIndexNowShared } from '../dist/runtime/server/utils/indexnow-shared.js';
|
|
14
15
|
import { buildLlmsFullTxtHeader, formatPageForLlmsFullTxt } from '../dist/runtime/server/utils/llms-full.js';
|
|
15
16
|
import { join as join$1, isAbsolute } from 'pathe';
|
|
16
17
|
|
|
@@ -67,7 +68,43 @@ function hookNuxtSeoProLicense() {
|
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
function
|
|
71
|
+
async function fetchPreviousMeta(siteUrl, indexNowKey) {
|
|
72
|
+
const metaUrl = `${siteUrl}/__ai-ready/pages.meta.json`;
|
|
73
|
+
logger.debug(`[indexnow] Fetching previous meta from ${metaUrl}`);
|
|
74
|
+
const prevMeta = await fetch(metaUrl).then((r) => r.ok ? r.json() : null).catch(() => null);
|
|
75
|
+
if (!prevMeta?.pages) {
|
|
76
|
+
logger.info("[indexnow] First deploy or no previous meta - skipping IndexNow");
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const keyUrl = `${siteUrl}/${indexNowKey}.txt`;
|
|
80
|
+
const keyLive = await fetch(keyUrl).then((r) => r.ok).catch(() => false);
|
|
81
|
+
if (!keyLive) {
|
|
82
|
+
logger.info("[indexnow] Key file not live yet - skipping IndexNow");
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return prevMeta;
|
|
86
|
+
}
|
|
87
|
+
async function handleStaticIndexNow(currentPages, siteUrl, indexNowKey, prevMeta) {
|
|
88
|
+
const { changed, added } = comparePageHashes(currentPages, prevMeta);
|
|
89
|
+
const totalChanged = changed.length + added.length;
|
|
90
|
+
if (totalChanged === 0) {
|
|
91
|
+
logger.debug("[indexnow] No content changes detected");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
logger.info(`[indexnow] Submitting ${totalChanged} changed pages (${changed.length} modified, ${added.length} new)`);
|
|
95
|
+
const result = await submitToIndexNowShared(
|
|
96
|
+
[...changed, ...added],
|
|
97
|
+
indexNowKey,
|
|
98
|
+
siteUrl,
|
|
99
|
+
{ logger }
|
|
100
|
+
);
|
|
101
|
+
if (result.success) {
|
|
102
|
+
logger.info(`[indexnow] Successfully notified search engines of ${totalChanged} changes`);
|
|
103
|
+
} else {
|
|
104
|
+
logger.warn(`[indexnow] Failed to submit: ${result.error}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function createCrawlerState(dbPath, llmsFullTxtPath, siteInfo, llmsTxtConfig, indexNowKey) {
|
|
71
108
|
return {
|
|
72
109
|
prerenderedRoutes: /* @__PURE__ */ new Set(),
|
|
73
110
|
errorRoutes: /* @__PURE__ */ new Set(),
|
|
@@ -76,7 +113,8 @@ function createCrawlerState(dbPath, llmsFullTxtPath, siteInfo, llmsTxtConfig) {
|
|
|
76
113
|
dbPath,
|
|
77
114
|
llmsFullTxtPath,
|
|
78
115
|
siteInfo,
|
|
79
|
-
llmsTxtConfig
|
|
116
|
+
llmsTxtConfig,
|
|
117
|
+
indexNowKey
|
|
80
118
|
};
|
|
81
119
|
}
|
|
82
120
|
async function initCrawler(state) {
|
|
@@ -227,11 +265,11 @@ async function prerenderRoute(nitro, route) {
|
|
|
227
265
|
nitro._prerenderedRoutes.push(_route);
|
|
228
266
|
return stat(filePath);
|
|
229
267
|
}
|
|
230
|
-
function setupPrerenderHandler(dbPath, siteInfo, llmsTxtConfig) {
|
|
268
|
+
function setupPrerenderHandler(dbPath, siteInfo, llmsTxtConfig, indexNowKey) {
|
|
231
269
|
const nuxt = useNuxt();
|
|
232
270
|
nuxt.hooks.hook("nitro:init", async (nitro) => {
|
|
233
271
|
const llmsFullTxtPath = join(nitro.options.output.publicDir, "llms-full.txt");
|
|
234
|
-
const state = createCrawlerState(dbPath, llmsFullTxtPath, siteInfo, llmsTxtConfig);
|
|
272
|
+
const state = createCrawlerState(dbPath, llmsFullTxtPath, siteInfo, llmsTxtConfig, indexNowKey);
|
|
235
273
|
let initPromise = null;
|
|
236
274
|
nitro.hooks.hook("prerender:generate", async (route) => {
|
|
237
275
|
if (route.error) {
|
|
@@ -294,6 +332,23 @@ function setupPrerenderHandler(dbPath, siteInfo, llmsTxtConfig) {
|
|
|
294
332
|
const dumpPath = join(publicDataDir, "pages.dump");
|
|
295
333
|
await writeFile(dumpPath, dumpData, "utf-8");
|
|
296
334
|
logger.debug(`Created database dump at __ai-ready/pages.dump (${(dumpData.length / 1024).toFixed(1)}kb compressed)`);
|
|
335
|
+
const pageHashes = pages.filter((p) => p.contentHash).map((p) => ({ route: p.route, hash: p.contentHash }));
|
|
336
|
+
let prevMeta = null;
|
|
337
|
+
if (state.indexNowKey && state.siteInfo?.url) {
|
|
338
|
+
prevMeta = await fetchPreviousMeta(state.siteInfo.url, state.indexNowKey);
|
|
339
|
+
}
|
|
340
|
+
const buildId = Date.now().toString(36);
|
|
341
|
+
const metaContent = JSON.stringify({
|
|
342
|
+
buildId,
|
|
343
|
+
pageCount: pages.length,
|
|
344
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
345
|
+
pages: pageHashes
|
|
346
|
+
});
|
|
347
|
+
await writeFile(join(publicDataDir, "pages.meta.json"), metaContent, "utf-8");
|
|
348
|
+
logger.debug(`Wrote build metadata: buildId=${buildId}, ${pageHashes.length} page hashes`);
|
|
349
|
+
if (state.indexNowKey && state.siteInfo?.url && prevMeta) {
|
|
350
|
+
await handleStaticIndexNow(pageHashes, state.siteInfo.url, state.indexNowKey, prevMeta);
|
|
351
|
+
}
|
|
297
352
|
}
|
|
298
353
|
const llmsStats = await prerenderRoute(nitro, "/llms.txt");
|
|
299
354
|
const llmsFullStats = await stat(state.llmsFullTxtPath);
|
|
@@ -666,9 +721,6 @@ export const errorRoutes = []`;
|
|
|
666
721
|
runtimeSyncSecret: config.runtimeSyncSecret,
|
|
667
722
|
indexNowKey
|
|
668
723
|
};
|
|
669
|
-
nuxt.options.nitro.plugins = nuxt.options.nitro.plugins || [];
|
|
670
|
-
if (runtimeSyncEnabled)
|
|
671
|
-
nuxt.options.nitro.plugins.push(resolve("./runtime/server/plugins/sitemap-seeder"));
|
|
672
724
|
addServerHandler({
|
|
673
725
|
middleware: true,
|
|
674
726
|
handler: resolve("./runtime/server/middleware/markdown.prerender")
|
|
@@ -721,7 +773,7 @@ export const errorRoutes = []`;
|
|
|
721
773
|
name: siteConfig.name,
|
|
722
774
|
url: siteConfig.url,
|
|
723
775
|
description: siteConfig.description
|
|
724
|
-
}, mergedLlmsTxt);
|
|
776
|
+
}, mergedLlmsTxt, indexNowKey);
|
|
725
777
|
}
|
|
726
778
|
nuxt.options.nitro.routeRules = nuxt.options.nitro.routeRules || {};
|
|
727
779
|
nuxt.options.nitro.routeRules["/llms.txt"] = { headers: { "Content-Type": "text/plain; charset=utf-8" } };
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { useDatabase } from './server/db/index.js';
|
|
2
2
|
export type { DatabaseAdapter } from './server/db/index.js';
|
|
3
|
-
export { countPages,
|
|
3
|
+
export { countPages, getStaleRoutes, isPageFresh, pruneStaleRoutes, queryPages, searchPages, seedRoutes, streamPages, upsertPage, } from './server/db/queries.js';
|
|
4
4
|
export type { CountPagesOptions, PageData, PageEntry, PageRow, QueryPagesOptions, SearchPagesOptions, SearchResult, StreamPagesOptions, UpsertPageInput, } from './server/db/queries.js';
|
|
5
5
|
export { indexPage, indexPageByRoute } from './server/utils/indexPage.js';
|
|
6
6
|
export type { IndexPageOptions, IndexPageResult } from './server/utils/indexPage.js';
|
package/dist/runtime/index.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
export { useDatabase } from "./server/db/index.js";
|
|
2
2
|
export {
|
|
3
3
|
countPages,
|
|
4
|
-
getSitemapSeededAt,
|
|
5
4
|
getStaleRoutes,
|
|
6
5
|
isPageFresh,
|
|
7
6
|
pruneStaleRoutes,
|
|
8
7
|
queryPages,
|
|
9
8
|
searchPages,
|
|
10
9
|
seedRoutes,
|
|
11
|
-
setSitemapSeededAt,
|
|
12
10
|
streamPages,
|
|
13
11
|
upsertPage
|
|
14
12
|
} from "./server/db/queries.js";
|
|
@@ -154,6 +154,11 @@ export declare function markIndexNowSynced(event: H3Event | undefined, routes: s
|
|
|
154
154
|
* Uses atomic SQL to handle concurrent updates safely
|
|
155
155
|
*/
|
|
156
156
|
export declare function updateIndexNowStats(event: H3Event | undefined, submitted: number, error?: string): Promise<void>;
|
|
157
|
+
/**
|
|
158
|
+
* Batch update for IndexNow: mark pages synced + update stats
|
|
159
|
+
* Runs all queries in parallel for speed
|
|
160
|
+
*/
|
|
161
|
+
export declare function batchIndexNowUpdate(event: H3Event | undefined, routes: string[], submitted: number): Promise<void>;
|
|
157
162
|
export interface IndexNowStats {
|
|
158
163
|
totalSubmitted: number;
|
|
159
164
|
lastSubmittedAt: number | null;
|
|
@@ -363,6 +363,32 @@ export async function updateIndexNowStats(event, submitted, error) {
|
|
|
363
363
|
);
|
|
364
364
|
}
|
|
365
365
|
}
|
|
366
|
+
export async function batchIndexNowUpdate(event, routes, submitted) {
|
|
367
|
+
const db = await getDb(event);
|
|
368
|
+
if (!db || routes.length === 0)
|
|
369
|
+
return;
|
|
370
|
+
const now = Date.now();
|
|
371
|
+
const placeholders = routes.map(() => "?").join(",");
|
|
372
|
+
await Promise.all([
|
|
373
|
+
// Mark pages as synced
|
|
374
|
+
db.exec(
|
|
375
|
+
`UPDATE ai_ready_pages SET indexnow_synced_at = ? WHERE route IN (${placeholders})`,
|
|
376
|
+
[now, ...routes]
|
|
377
|
+
),
|
|
378
|
+
// Atomic increment total submitted
|
|
379
|
+
db.exec(`
|
|
380
|
+
INSERT INTO _ai_ready_info (id, value) VALUES ('indexnow_total_submitted', ?)
|
|
381
|
+
ON CONFLICT(id) DO UPDATE SET value = CAST((CAST(value AS INTEGER) + ?) AS TEXT)
|
|
382
|
+
`, [String(submitted), submitted]),
|
|
383
|
+
// Update last submitted timestamp
|
|
384
|
+
db.exec(
|
|
385
|
+
"INSERT OR REPLACE INTO _ai_ready_info (id, value) VALUES (?, ?)",
|
|
386
|
+
["indexnow_last_submitted_at", String(now)]
|
|
387
|
+
),
|
|
388
|
+
// Clear any previous error
|
|
389
|
+
db.exec("DELETE FROM _ai_ready_info WHERE id = ?", ["indexnow_last_error"])
|
|
390
|
+
]);
|
|
391
|
+
}
|
|
366
392
|
export async function getIndexNowStats(event) {
|
|
367
393
|
const db = await getDb(event);
|
|
368
394
|
if (!db)
|
|
@@ -13,7 +13,8 @@ export interface DatabaseAdapter {
|
|
|
13
13
|
*/
|
|
14
14
|
export declare function createAdapter(connector: Connector): DatabaseAdapter;
|
|
15
15
|
/**
|
|
16
|
-
* Initialize database schema with version checking
|
|
16
|
+
* Initialize database schema with version checking
|
|
17
|
+
* Note: Restore logic moved to cron for reliability on Cloudflare
|
|
17
18
|
*/
|
|
18
19
|
export declare function initSchema(db: DatabaseAdapter): Promise<void>;
|
|
19
20
|
/**
|
|
@@ -98,6 +99,7 @@ export interface DumpRow {
|
|
|
98
99
|
indexed: number;
|
|
99
100
|
source: string;
|
|
100
101
|
last_seen_at: number | null;
|
|
102
|
+
indexnow_synced_at: number | null;
|
|
101
103
|
}
|
|
102
104
|
/**
|
|
103
105
|
* Export database as compressed dump (base64 gzip) using batched streaming
|
|
@@ -106,5 +108,7 @@ export interface DumpRow {
|
|
|
106
108
|
export declare function exportDbDump(db: DatabaseAdapter): Promise<string>;
|
|
107
109
|
/**
|
|
108
110
|
* Import dump into database
|
|
111
|
+
* Sets indexed=1, last_seen_at=indexed_at, indexnow_synced_at=indexed_at
|
|
112
|
+
* (pages from dump were already indexed and synced during build, no need to re-notify)
|
|
109
113
|
*/
|
|
110
114
|
export declare function importDbDump(db: DatabaseAdapter, rows: DumpRow[]): Promise<void>;
|
|
@@ -35,33 +35,6 @@ export async function initSchema(db) {
|
|
|
35
35
|
"INSERT OR REPLACE INTO _ai_ready_info (id, version) VALUES (?, ?)",
|
|
36
36
|
["schema", SCHEMA_VERSION]
|
|
37
37
|
);
|
|
38
|
-
if (!import.meta.prerender && !import.meta.dev) {
|
|
39
|
-
await restoreFromDumpIfEmpty(db);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
async function restoreFromDumpIfEmpty(db) {
|
|
43
|
-
const row = await db.first("SELECT COUNT(*) as count FROM ai_ready_pages");
|
|
44
|
-
if (row && row.count > 0)
|
|
45
|
-
return;
|
|
46
|
-
try {
|
|
47
|
-
let dumpData = null;
|
|
48
|
-
const cfEnv = globalThis.__env__;
|
|
49
|
-
if (cfEnv?.ASSETS) {
|
|
50
|
-
const response = await cfEnv.ASSETS.fetch("https://placeholder/__ai-ready/pages.dump");
|
|
51
|
-
if (response.ok)
|
|
52
|
-
dumpData = await response.text();
|
|
53
|
-
}
|
|
54
|
-
if (!dumpData) {
|
|
55
|
-
dumpData = await globalThis.$fetch("/__ai-ready/pages.dump", {
|
|
56
|
-
responseType: "text"
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
if (!dumpData)
|
|
60
|
-
return;
|
|
61
|
-
const rows = await decompressFromBase64(dumpData);
|
|
62
|
-
await importDbDump(db, rows);
|
|
63
|
-
} catch {
|
|
64
|
-
}
|
|
65
38
|
}
|
|
66
39
|
async function checkSchemaVersion(db) {
|
|
67
40
|
const info = await db.first(
|
|
@@ -147,7 +120,7 @@ export async function exportDbDump(db) {
|
|
|
147
120
|
let first = true;
|
|
148
121
|
while (true) {
|
|
149
122
|
const rows = await db.all(`
|
|
150
|
-
SELECT route, route_key, title, description, markdown, headings, keywords, content_hash, updated_at, indexed_at, is_error, indexed, source, last_seen_at
|
|
123
|
+
SELECT route, route_key, title, description, markdown, headings, keywords, content_hash, updated_at, indexed_at, is_error, indexed, source, last_seen_at, indexnow_synced_at
|
|
151
124
|
FROM ai_ready_pages
|
|
152
125
|
ORDER BY route
|
|
153
126
|
LIMIT ${DUMP_BATCH_SIZE} OFFSET ${offset}
|
|
@@ -179,8 +152,8 @@ export async function importDbDump(db, rows) {
|
|
|
179
152
|
for (const row of rows) {
|
|
180
153
|
const source = row.source || "prerender";
|
|
181
154
|
await db.exec(`
|
|
182
|
-
INSERT OR REPLACE INTO ai_ready_pages (route, route_key, title, description, markdown, headings, keywords, content_hash, updated_at, indexed_at, is_error, indexed, source, last_seen_at)
|
|
183
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?,
|
|
184
|
-
`, [row.route, row.route_key, row.title, row.description, row.markdown, row.headings, row.keywords, row.content_hash || null, row.updated_at, row.indexed_at, row.is_error, source]);
|
|
155
|
+
INSERT OR REPLACE INTO ai_ready_pages (route, route_key, title, description, markdown, headings, keywords, content_hash, updated_at, indexed_at, is_error, indexed, source, last_seen_at, indexnow_synced_at)
|
|
156
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
|
|
157
|
+
`, [row.route, row.route_key, row.title, row.description, row.markdown, row.headings, row.keywords, row.content_hash || null, row.updated_at, row.indexed_at, row.is_error, source, row.indexed_at, row.indexed_at]);
|
|
185
158
|
}
|
|
186
159
|
}
|
|
@@ -26,13 +26,18 @@ interface DebugInfo {
|
|
|
26
26
|
total: number;
|
|
27
27
|
indexed: number;
|
|
28
28
|
pending: number;
|
|
29
|
-
|
|
29
|
+
errors: number;
|
|
30
30
|
};
|
|
31
31
|
indexNow?: {
|
|
32
32
|
pending: number;
|
|
33
33
|
totalSubmitted: number;
|
|
34
34
|
lastSubmittedAt: string | null;
|
|
35
35
|
lastError: string | null;
|
|
36
|
+
backoff?: {
|
|
37
|
+
until: string;
|
|
38
|
+
minutesRemaining: number;
|
|
39
|
+
attempt: number;
|
|
40
|
+
} | null;
|
|
36
41
|
};
|
|
37
42
|
indexNowLog?: Array<{
|
|
38
43
|
id: number;
|
|
@@ -58,6 +63,13 @@ interface DebugInfo {
|
|
|
58
63
|
pageCount: number;
|
|
59
64
|
source: string;
|
|
60
65
|
};
|
|
66
|
+
buildInfo?: {
|
|
67
|
+
storedBuildId: string | null;
|
|
68
|
+
dumpBuildId: string | null;
|
|
69
|
+
dumpPageCount: number | null;
|
|
70
|
+
isStale: boolean;
|
|
71
|
+
dumpCreatedAt: string | null;
|
|
72
|
+
};
|
|
61
73
|
virtualModules: {
|
|
62
74
|
pageDataModule: {
|
|
63
75
|
available: boolean;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createError, eventHandler, setHeader } from "h3";
|
|
2
2
|
import { useRuntimeConfig } from "nitropack/runtime";
|
|
3
|
-
import {
|
|
3
|
+
import { useDatabase } from "../db/index.js";
|
|
4
|
+
import { countPages, countPagesNeedingIndexNowSync, getIndexNowLog, getIndexNowStats, getRecentCronRuns, queryPages } from "../db/queries.js";
|
|
4
5
|
export default eventHandler(async (event) => {
|
|
5
6
|
const runtimeConfig = useRuntimeConfig(event)["nuxt-ai-ready"];
|
|
6
7
|
if (!runtimeConfig.debug) {
|
|
@@ -107,19 +108,20 @@ export default eventHandler(async (event) => {
|
|
|
107
108
|
let indexNowInfo;
|
|
108
109
|
let indexNowLogInfo;
|
|
109
110
|
let cronRunsInfo;
|
|
111
|
+
let buildInfo;
|
|
110
112
|
if (!isDev && !isPrerender && !dbError) {
|
|
111
113
|
try {
|
|
112
|
-
const [total, pending,
|
|
114
|
+
const [total, pending, errors, cronRuns] = await Promise.all([
|
|
113
115
|
countPages(event),
|
|
114
116
|
countPages(event, { where: { pending: true } }),
|
|
115
|
-
|
|
117
|
+
countPages(event, { where: { hasError: true } }),
|
|
116
118
|
getRecentCronRuns(event, 20)
|
|
117
119
|
]);
|
|
118
120
|
runtimeSyncInfo = {
|
|
119
121
|
total,
|
|
120
122
|
indexed: total - pending,
|
|
121
123
|
pending,
|
|
122
|
-
|
|
124
|
+
errors
|
|
123
125
|
};
|
|
124
126
|
cronRunsInfo = cronRuns.map((run) => ({
|
|
125
127
|
id: run.id,
|
|
@@ -139,11 +141,26 @@ export default eventHandler(async (event) => {
|
|
|
139
141
|
getIndexNowStats(event),
|
|
140
142
|
getIndexNowLog(event, 20)
|
|
141
143
|
]);
|
|
144
|
+
const db2 = await useDatabase(event);
|
|
145
|
+
const backoffRow = await db2.first("SELECT value FROM _ai_ready_info WHERE id = ?", ["indexnow_backoff"]);
|
|
146
|
+
let backoffInfo = null;
|
|
147
|
+
if (backoffRow) {
|
|
148
|
+
const parsed = JSON.parse(backoffRow.value);
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
if (parsed.until > now) {
|
|
151
|
+
backoffInfo = {
|
|
152
|
+
until: new Date(parsed.until).toISOString(),
|
|
153
|
+
minutesRemaining: Math.ceil((parsed.until - now) / 6e4),
|
|
154
|
+
attempt: parsed.attempt
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
142
158
|
indexNowInfo = {
|
|
143
159
|
pending: indexNowPending,
|
|
144
160
|
totalSubmitted: indexNowStats.totalSubmitted,
|
|
145
161
|
lastSubmittedAt: indexNowStats.lastSubmittedAt ? new Date(indexNowStats.lastSubmittedAt).toISOString() : null,
|
|
146
|
-
lastError: indexNowStats.lastError
|
|
162
|
+
lastError: indexNowStats.lastError,
|
|
163
|
+
backoff: backoffInfo
|
|
147
164
|
};
|
|
148
165
|
indexNowLogInfo = indexNowLogEntries.map((entry) => ({
|
|
149
166
|
id: entry.id,
|
|
@@ -153,6 +170,33 @@ export default eventHandler(async (event) => {
|
|
|
153
170
|
error: entry.error
|
|
154
171
|
}));
|
|
155
172
|
}
|
|
173
|
+
const db = await useDatabase(event);
|
|
174
|
+
const storedRow = await db.first("SELECT value FROM _ai_ready_info WHERE id = ?", ["build_id"]);
|
|
175
|
+
const storedBuildId = storedRow?.value || null;
|
|
176
|
+
let dumpMeta = null;
|
|
177
|
+
const cfEnv2 = event.context?.cloudflare?.env;
|
|
178
|
+
if (cfEnv2?.ASSETS?.fetch) {
|
|
179
|
+
const metaResponse = await cfEnv2.ASSETS.fetch(new Request("https://assets.local/__ai-ready/pages.meta.json")).catch(() => null);
|
|
180
|
+
if (metaResponse?.ok)
|
|
181
|
+
dumpMeta = await metaResponse.json().catch(() => null);
|
|
182
|
+
}
|
|
183
|
+
if (!dumpMeta) {
|
|
184
|
+
dumpMeta = await globalThis.$fetch("/__ai-ready/pages.meta.json").catch(() => null);
|
|
185
|
+
}
|
|
186
|
+
buildInfo = {
|
|
187
|
+
storedBuildId,
|
|
188
|
+
dumpBuildId: dumpMeta?.buildId || null,
|
|
189
|
+
dumpPageCount: dumpMeta?.pageCount || null,
|
|
190
|
+
isStale: dumpMeta ? storedBuildId !== dumpMeta.buildId : false,
|
|
191
|
+
dumpCreatedAt: dumpMeta?.createdAt || null
|
|
192
|
+
};
|
|
193
|
+
if (buildInfo.isStale) {
|
|
194
|
+
issues.push(`Build ID mismatch: DB has "${storedBuildId || "none"}", dump has "${dumpMeta?.buildId}"`);
|
|
195
|
+
suggestions.push("Cron will mark pages pending on next run, or manually trigger /__ai-ready/cron");
|
|
196
|
+
} else if (!storedBuildId && dumpMeta) {
|
|
197
|
+
issues.push("No build ID stored in DB - data may need restore");
|
|
198
|
+
suggestions.push("Wait for cron to run, or manually trigger /__ai-ready/cron");
|
|
199
|
+
}
|
|
156
200
|
} catch (err) {
|
|
157
201
|
issues.push(`Runtime stats error: ${err.message || String(err)}`);
|
|
158
202
|
}
|
|
@@ -186,6 +230,7 @@ export default eventHandler(async (event) => {
|
|
|
186
230
|
indexNow: indexNowInfo,
|
|
187
231
|
indexNowLog: indexNowLogInfo,
|
|
188
232
|
cronRuns: cronRunsInfo,
|
|
233
|
+
buildInfo,
|
|
189
234
|
pageData: {
|
|
190
235
|
source,
|
|
191
236
|
pageCount: pages.length,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { H3Event } from 'h3';
|
|
2
|
+
export interface BuildMeta {
|
|
3
|
+
buildId: string;
|
|
4
|
+
pageCount: number;
|
|
5
|
+
createdAt: string;
|
|
6
|
+
}
|
|
7
|
+
export interface StaleCheckResult {
|
|
8
|
+
action: 'none' | 'restored' | 'marked_pending';
|
|
9
|
+
buildId?: string;
|
|
10
|
+
dbCount: number;
|
|
11
|
+
dumpCount?: number;
|
|
12
|
+
reason?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Check if data is stale and handle restore/mark-pending
|
|
16
|
+
* Called at start of cron - handles:
|
|
17
|
+
* 1. Empty DB → restore from dump
|
|
18
|
+
* 2. Build ID changed → mark all pages pending for recheck
|
|
19
|
+
*/
|
|
20
|
+
export declare function checkAndHandleStale(event: H3Event): Promise<StaleCheckResult>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useRuntimeConfig } from "nitropack/runtime";
|
|
2
|
+
import { useDatabase } from "../db/index.js";
|
|
3
|
+
import { countPages } from "../db/queries.js";
|
|
4
|
+
import { decompressFromBase64, importDbDump } from "../db/shared.js";
|
|
5
|
+
import { logger } from "../logger.js";
|
|
6
|
+
async function fetchBuildMeta(event) {
|
|
7
|
+
const cfEnv = event.context?.cloudflare?.env;
|
|
8
|
+
if (cfEnv?.ASSETS?.fetch) {
|
|
9
|
+
const response = await cfEnv.ASSETS.fetch(new Request("https://assets.local/__ai-ready/pages.meta.json")).catch(() => null);
|
|
10
|
+
if (response?.ok) {
|
|
11
|
+
return response.json().catch(() => null);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return globalThis.$fetch("/__ai-ready/pages.meta.json").catch(() => null);
|
|
15
|
+
}
|
|
16
|
+
async function fetchDump(event) {
|
|
17
|
+
const cfEnv = event.context?.cloudflare?.env;
|
|
18
|
+
let dumpData = null;
|
|
19
|
+
if (cfEnv?.ASSETS?.fetch) {
|
|
20
|
+
const response = await cfEnv.ASSETS.fetch(new Request("https://assets.local/__ai-ready/pages.dump")).catch(() => null);
|
|
21
|
+
if (response?.ok) {
|
|
22
|
+
dumpData = await response.text();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (!dumpData) {
|
|
26
|
+
dumpData = await globalThis.$fetch("/__ai-ready/pages.dump", { responseType: "text" }).catch(() => null);
|
|
27
|
+
}
|
|
28
|
+
if (!dumpData)
|
|
29
|
+
return null;
|
|
30
|
+
return decompressFromBase64(dumpData);
|
|
31
|
+
}
|
|
32
|
+
async function getStoredBuildId(event) {
|
|
33
|
+
const db = await useDatabase(event);
|
|
34
|
+
const row = await db.first("SELECT value FROM _ai_ready_info WHERE id = ?", ["build_id"]);
|
|
35
|
+
return row?.value || null;
|
|
36
|
+
}
|
|
37
|
+
async function setStoredBuildId(event, buildId) {
|
|
38
|
+
const db = await useDatabase(event);
|
|
39
|
+
await db.exec("INSERT OR REPLACE INTO _ai_ready_info (id, value) VALUES (?, ?)", ["build_id", buildId]);
|
|
40
|
+
}
|
|
41
|
+
async function markAllPagesPending(event) {
|
|
42
|
+
const db = await useDatabase(event);
|
|
43
|
+
await db.exec("UPDATE ai_ready_pages SET indexed = 0 WHERE is_error = 0");
|
|
44
|
+
return countPages(event, { where: { pending: true } });
|
|
45
|
+
}
|
|
46
|
+
export async function checkAndHandleStale(event) {
|
|
47
|
+
const config = useRuntimeConfig()["nuxt-ai-ready"];
|
|
48
|
+
const debug = config.debug;
|
|
49
|
+
const dbCount = await countPages(event);
|
|
50
|
+
const storedBuildId = await getStoredBuildId(event);
|
|
51
|
+
const meta = await fetchBuildMeta(event);
|
|
52
|
+
if (debug) {
|
|
53
|
+
logger.info(`[stale-check] DB count: ${dbCount}, stored buildId: ${storedBuildId || "none"}, dump buildId: ${meta?.buildId || "none"}`);
|
|
54
|
+
}
|
|
55
|
+
if (!meta) {
|
|
56
|
+
if (debug)
|
|
57
|
+
logger.info("[stale-check] No build metadata found, skipping stale check");
|
|
58
|
+
return { action: "none", dbCount, reason: "no_dump_metadata" };
|
|
59
|
+
}
|
|
60
|
+
if (dbCount === 0) {
|
|
61
|
+
if (debug)
|
|
62
|
+
logger.info("[stale-check] DB empty, restoring from dump...");
|
|
63
|
+
const rows = await fetchDump(event);
|
|
64
|
+
if (!rows) {
|
|
65
|
+
logger.warn("[stale-check] Failed to fetch dump for restore");
|
|
66
|
+
return { action: "none", dbCount: 0, dumpCount: meta.pageCount, reason: "dump_fetch_failed" };
|
|
67
|
+
}
|
|
68
|
+
const db = await useDatabase(event);
|
|
69
|
+
await importDbDump(db, rows);
|
|
70
|
+
await setStoredBuildId(event, meta.buildId);
|
|
71
|
+
logger.info(`[stale-check] Restored ${rows.length} pages from dump (buildId: ${meta.buildId})`);
|
|
72
|
+
return {
|
|
73
|
+
action: "restored",
|
|
74
|
+
buildId: meta.buildId,
|
|
75
|
+
dbCount: rows.length,
|
|
76
|
+
dumpCount: meta.pageCount
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (storedBuildId !== meta.buildId) {
|
|
80
|
+
if (debug)
|
|
81
|
+
logger.info(`[stale-check] Build ID changed (${storedBuildId} \u2192 ${meta.buildId}), marking pages pending...`);
|
|
82
|
+
const pendingCount = await markAllPagesPending(event);
|
|
83
|
+
await setStoredBuildId(event, meta.buildId);
|
|
84
|
+
logger.info(`[stale-check] Marked ${pendingCount} pages as pending for recheck (buildId: ${meta.buildId})`);
|
|
85
|
+
return {
|
|
86
|
+
action: "marked_pending",
|
|
87
|
+
buildId: meta.buildId,
|
|
88
|
+
dbCount,
|
|
89
|
+
dumpCount: meta.pageCount
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (debug)
|
|
93
|
+
logger.info("[stale-check] Build ID unchanged, no action needed");
|
|
94
|
+
return {
|
|
95
|
+
action: "none",
|
|
96
|
+
buildId: meta.buildId,
|
|
97
|
+
dbCount,
|
|
98
|
+
dumpCount: meta.pageCount
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared IndexNow utilities for build-time and runtime
|
|
3
|
+
* This module has no Nuxt/Nitro dependencies so it can be used in both contexts
|
|
4
|
+
*/
|
|
5
|
+
export declare const INDEXNOW_HOSTS: string[];
|
|
6
|
+
/**
|
|
7
|
+
* Get IndexNow endpoints, with test override support
|
|
8
|
+
*/
|
|
9
|
+
export declare function getIndexNowEndpoints(): string[];
|
|
10
|
+
export interface IndexNowSubmitResult {
|
|
11
|
+
success: boolean;
|
|
12
|
+
error?: string;
|
|
13
|
+
host?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface IndexNowRequestBody {
|
|
16
|
+
host: string;
|
|
17
|
+
key: string;
|
|
18
|
+
keyLocation: string;
|
|
19
|
+
urlList: string[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Build the IndexNow API request body
|
|
23
|
+
*/
|
|
24
|
+
export declare function buildIndexNowBody(routes: string[], key: string, siteUrl: string): IndexNowRequestBody;
|
|
25
|
+
export interface SubmitOptions {
|
|
26
|
+
/** Custom fetch implementation (defaults to globalThis.fetch) */
|
|
27
|
+
fetchFn?: typeof fetch;
|
|
28
|
+
/** Logger for debug/warn messages (optional) */
|
|
29
|
+
logger?: {
|
|
30
|
+
debug: (msg: string) => void;
|
|
31
|
+
warn: (msg: string) => void;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Submit URLs to IndexNow API with fallback on rate limit
|
|
36
|
+
* Works in both build-time (native fetch) and runtime ($fetch) contexts
|
|
37
|
+
*/
|
|
38
|
+
export declare function submitToIndexNowShared(routes: string[], key: string, siteUrl: string, options?: SubmitOptions): Promise<IndexNowSubmitResult>;
|
|
39
|
+
export interface PageHashMeta {
|
|
40
|
+
route: string;
|
|
41
|
+
hash: string;
|
|
42
|
+
}
|
|
43
|
+
export interface BuildMeta {
|
|
44
|
+
buildId: string;
|
|
45
|
+
pageCount: number;
|
|
46
|
+
createdAt: string;
|
|
47
|
+
pages: PageHashMeta[];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Compare page hashes between current and previous builds
|
|
51
|
+
* Returns changed, added, and removed routes
|
|
52
|
+
*/
|
|
53
|
+
export declare function comparePageHashes(currentPages: PageHashMeta[], prevMeta: BuildMeta | null | undefined): {
|
|
54
|
+
changed: string[];
|
|
55
|
+
added: string[];
|
|
56
|
+
removed: string[];
|
|
57
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export const INDEXNOW_HOSTS = ["api.indexnow.org", "www.bing.com"];
|
|
2
|
+
export function getIndexNowEndpoints() {
|
|
3
|
+
const testEndpoint = process.env.INDEXNOW_TEST_ENDPOINT;
|
|
4
|
+
if (testEndpoint) {
|
|
5
|
+
return [testEndpoint];
|
|
6
|
+
}
|
|
7
|
+
return INDEXNOW_HOSTS.map((host) => `https://${host}/indexnow`);
|
|
8
|
+
}
|
|
9
|
+
export function buildIndexNowBody(routes, key, siteUrl) {
|
|
10
|
+
const urlList = routes.map(
|
|
11
|
+
(route) => route.startsWith("http") ? route : `${siteUrl}${route}`
|
|
12
|
+
);
|
|
13
|
+
return {
|
|
14
|
+
host: new URL(siteUrl).host,
|
|
15
|
+
key,
|
|
16
|
+
keyLocation: `${siteUrl}/${key}.txt`,
|
|
17
|
+
urlList
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export async function submitToIndexNowShared(routes, key, siteUrl, options) {
|
|
21
|
+
if (!siteUrl) {
|
|
22
|
+
return { success: false, error: "Site URL not configured" };
|
|
23
|
+
}
|
|
24
|
+
const fetchFn = options?.fetchFn ?? globalThis.fetch;
|
|
25
|
+
const log = options?.logger;
|
|
26
|
+
const body = buildIndexNowBody(routes, key, siteUrl);
|
|
27
|
+
const endpoints = getIndexNowEndpoints();
|
|
28
|
+
let lastError;
|
|
29
|
+
for (const endpoint of endpoints) {
|
|
30
|
+
log?.debug(`[indexnow] Submitting ${body.urlList.length} URLs to ${endpoint}`);
|
|
31
|
+
const response = await fetchFn(endpoint, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "Content-Type": "application/json" },
|
|
34
|
+
body: JSON.stringify(body)
|
|
35
|
+
}).then((r) => r.ok ? { ok: true } : { error: `HTTP ${r.status}` }).catch((err) => ({ error: err.message }));
|
|
36
|
+
if ("error" in response) {
|
|
37
|
+
lastError = response.error;
|
|
38
|
+
if (lastError?.includes("429")) {
|
|
39
|
+
log?.warn(`[indexnow] Rate limited on ${endpoint}, trying fallback...`);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
log?.warn(`[indexnow] Submission failed on ${endpoint}: ${lastError}`);
|
|
43
|
+
return { success: false, error: lastError, host: endpoint };
|
|
44
|
+
}
|
|
45
|
+
log?.debug(`[indexnow] Successfully submitted ${body.urlList.length} URLs via ${endpoint}`);
|
|
46
|
+
return { success: true, host: endpoint };
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
success: false,
|
|
50
|
+
error: lastError || "All endpoints rate limited",
|
|
51
|
+
host: endpoints[endpoints.length - 1]
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export function comparePageHashes(currentPages, prevMeta) {
|
|
55
|
+
if (!prevMeta?.pages) {
|
|
56
|
+
return { changed: [], added: [], removed: [] };
|
|
57
|
+
}
|
|
58
|
+
const prevHashes = new Map(prevMeta.pages.map((p) => [p.route, p.hash]));
|
|
59
|
+
const currentRoutes = new Set(currentPages.map((p) => p.route));
|
|
60
|
+
const changed = [];
|
|
61
|
+
const added = [];
|
|
62
|
+
for (const page of currentPages) {
|
|
63
|
+
const prevHash = prevHashes.get(page.route);
|
|
64
|
+
if (!prevHash) {
|
|
65
|
+
added.push(page.route);
|
|
66
|
+
} else if (prevHash !== page.hash) {
|
|
67
|
+
changed.push(page.route);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const removed = [];
|
|
71
|
+
for (const route of prevHashes.keys()) {
|
|
72
|
+
if (!currentRoutes.has(route)) {
|
|
73
|
+
removed.push(route);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { changed, added, removed };
|
|
77
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { H3Event } from 'h3';
|
|
2
|
+
export type { BuildMeta, IndexNowSubmitResult, PageHashMeta } from './indexnow-shared.js';
|
|
2
3
|
export interface IndexNowResult {
|
|
3
4
|
success: boolean;
|
|
4
5
|
submitted: number;
|
|
@@ -8,6 +9,7 @@ export interface IndexNowResult {
|
|
|
8
9
|
}
|
|
9
10
|
/**
|
|
10
11
|
* Submit URLs to IndexNow API with fallback on rate limit
|
|
12
|
+
* Wrapper around shared implementation with runtime-specific fetch
|
|
11
13
|
*/
|
|
12
14
|
export declare function submitToIndexNow(routes: string[], config: {
|
|
13
15
|
key: string;
|
|
@@ -16,9 +18,13 @@ export declare function submitToIndexNow(routes: string[], config: {
|
|
|
16
18
|
error?: string;
|
|
17
19
|
host?: string;
|
|
18
20
|
}>;
|
|
21
|
+
export interface SyncToIndexNowOptions {
|
|
22
|
+
/** Use waitUntil for background DB updates (default: false in cron, true otherwise) */
|
|
23
|
+
useWaitUntil?: boolean;
|
|
24
|
+
}
|
|
19
25
|
/**
|
|
20
26
|
* Submit pending pages to IndexNow
|
|
21
27
|
* Queries DB for pages needing sync, submits, marks as synced
|
|
22
28
|
* Implements exponential backoff on 429 rate limit errors
|
|
23
29
|
*/
|
|
24
|
-
export declare function syncToIndexNow(event: H3Event | undefined, limit?: number): Promise<IndexNowResult>;
|
|
30
|
+
export declare function syncToIndexNow(event: H3Event | undefined, limit?: number, options?: SyncToIndexNowOptions): Promise<IndexNowResult>;
|
|
@@ -2,13 +2,14 @@ import { getSiteConfig } from "#site-config/server/composables";
|
|
|
2
2
|
import { useRuntimeConfig } from "nitropack/runtime";
|
|
3
3
|
import { useDatabase } from "../db/index.js";
|
|
4
4
|
import {
|
|
5
|
+
batchIndexNowUpdate,
|
|
5
6
|
countPagesNeedingIndexNowSync,
|
|
6
7
|
getPagesNeedingIndexNowSync,
|
|
7
8
|
logIndexNowSubmission,
|
|
8
|
-
markIndexNowSynced,
|
|
9
9
|
updateIndexNowStats
|
|
10
10
|
} from "../db/queries.js";
|
|
11
11
|
import { logger } from "../logger.js";
|
|
12
|
+
import { submitToIndexNowShared } from "./indexnow-shared.js";
|
|
12
13
|
const BACKOFF_MINUTES = [5, 10, 20, 40, 60];
|
|
13
14
|
async function getBackoffInfo(event) {
|
|
14
15
|
const db = await useDatabase(event).catch(() => null);
|
|
@@ -30,44 +31,26 @@ async function setBackoffInfo(event, info) {
|
|
|
30
31
|
await db.exec("DELETE FROM _ai_ready_info WHERE id = ?", ["indexnow_backoff"]);
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
|
-
const INDEXNOW_HOSTS = ["api.indexnow.org", "www.bing.com"];
|
|
34
34
|
export async function submitToIndexNow(routes, config, siteUrl) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const body = {
|
|
42
|
-
host: new URL(siteUrl).host,
|
|
43
|
-
key: config.key,
|
|
44
|
-
keyLocation: `${siteUrl}/${config.key}.txt`,
|
|
45
|
-
urlList
|
|
46
|
-
};
|
|
47
|
-
let lastError;
|
|
48
|
-
for (const host of INDEXNOW_HOSTS) {
|
|
49
|
-
const endpoint = `https://${host}/indexnow`;
|
|
50
|
-
logger.debug(`[indexnow] Submitting ${urlList.length} URLs to ${endpoint}`);
|
|
51
|
-
const response = await $fetch(endpoint, {
|
|
52
|
-
method: "POST",
|
|
53
|
-
headers: { "Content-Type": "application/json" },
|
|
35
|
+
const runtimeFetch = async (input, init) => {
|
|
36
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
37
|
+
const body = init?.body ? JSON.parse(init.body) : void 0;
|
|
38
|
+
const result = await $fetch.raw(url, {
|
|
39
|
+
method: init?.method,
|
|
40
|
+
headers: init?.headers,
|
|
54
41
|
body
|
|
55
|
-
}).catch((err) => ({
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
if (lastError.includes("429")) {
|
|
59
|
-
logger.warn(`[indexnow] Rate limited on ${host}, trying fallback...`);
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
logger.warn(`[indexnow] Submission failed on ${host}: ${lastError}`);
|
|
63
|
-
return { success: false, error: lastError, host };
|
|
42
|
+
}).catch((err) => ({ _error: err.message }));
|
|
43
|
+
if (result && "_error" in result) {
|
|
44
|
+
return { ok: false, status: 500, statusText: result._error };
|
|
64
45
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
46
|
+
return { ok: result.status >= 200 && result.status < 300, status: result.status };
|
|
47
|
+
};
|
|
48
|
+
return submitToIndexNowShared(routes, config.key, siteUrl, {
|
|
49
|
+
fetchFn: runtimeFetch,
|
|
50
|
+
logger
|
|
51
|
+
});
|
|
69
52
|
}
|
|
70
|
-
export async function syncToIndexNow(event, limit = 100) {
|
|
53
|
+
export async function syncToIndexNow(event, limit = 100, options) {
|
|
71
54
|
const config = useRuntimeConfig(event)["nuxt-ai-ready"];
|
|
72
55
|
const siteConfig = getSiteConfig(event);
|
|
73
56
|
if (!config.indexNowKey) {
|
|
@@ -80,43 +63,55 @@ export async function syncToIndexNow(event, limit = 100) {
|
|
|
80
63
|
if (backoff && Date.now() < backoff.until) {
|
|
81
64
|
const waitMinutes = Math.ceil((backoff.until - Date.now()) / 6e4);
|
|
82
65
|
logger.debug(`[indexnow] In backoff period, ${waitMinutes}m remaining`);
|
|
83
|
-
const
|
|
66
|
+
const remaining = await countPagesNeedingIndexNowSync(event);
|
|
84
67
|
return {
|
|
85
68
|
success: false,
|
|
86
69
|
submitted: 0,
|
|
87
|
-
remaining
|
|
70
|
+
remaining,
|
|
88
71
|
error: `Rate limited, retry in ${waitMinutes}m`,
|
|
89
72
|
backoff: true
|
|
90
73
|
};
|
|
91
74
|
}
|
|
92
|
-
const pages = await
|
|
75
|
+
const [totalPending, pages] = await Promise.all([
|
|
76
|
+
countPagesNeedingIndexNowSync(event),
|
|
77
|
+
getPagesNeedingIndexNowSync(event, limit)
|
|
78
|
+
]);
|
|
93
79
|
if (pages.length === 0) {
|
|
94
80
|
return { success: true, submitted: 0, remaining: 0 };
|
|
95
81
|
}
|
|
96
82
|
const routes = pages.map((p) => p.route);
|
|
97
83
|
const result = await submitToIndexNow(routes, { key: config.indexNowKey }, siteConfig.url);
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
84
|
+
const dbUpdates = async () => {
|
|
85
|
+
if (config.debug) {
|
|
86
|
+
await logIndexNowSubmission(event, routes.length, result.success, result.error);
|
|
87
|
+
}
|
|
88
|
+
if (result.success) {
|
|
89
|
+
await setBackoffInfo(event, null);
|
|
90
|
+
await batchIndexNowUpdate(event, routes, routes.length);
|
|
91
|
+
logger.debug(`[indexnow] DB updated: ${routes.length} pages marked synced via ${result.host}`);
|
|
92
|
+
} else {
|
|
93
|
+
await updateIndexNowStats(event, 0, result.error);
|
|
94
|
+
if (result.error?.includes("429")) {
|
|
95
|
+
const attempt = backoff ? Math.min(backoff.attempt + 1, BACKOFF_MINUTES.length - 1) : 0;
|
|
96
|
+
const backoffMinutes = BACKOFF_MINUTES[attempt] ?? 60;
|
|
97
|
+
const until = Date.now() + backoffMinutes * 60 * 1e3;
|
|
98
|
+
await setBackoffInfo(event, { until, attempt });
|
|
99
|
+
logger.warn(`[indexnow] Rate limited, backing off for ${backoffMinutes}m (attempt ${attempt + 1})`);
|
|
100
|
+
}
|
|
113
101
|
}
|
|
102
|
+
};
|
|
103
|
+
if (options?.useWaitUntil && event?.waitUntil) {
|
|
104
|
+
event.waitUntil(dbUpdates().catch(
|
|
105
|
+
(err) => logger.error(`[indexnow] Background DB update failed: ${err.message}`)
|
|
106
|
+
));
|
|
107
|
+
} else {
|
|
108
|
+
await dbUpdates();
|
|
114
109
|
}
|
|
115
|
-
const
|
|
110
|
+
const submitted = result.success ? routes.length : 0;
|
|
116
111
|
return {
|
|
117
112
|
success: result.success,
|
|
118
|
-
submitted
|
|
119
|
-
remaining,
|
|
113
|
+
submitted,
|
|
114
|
+
remaining: Math.max(0, totalPending - submitted),
|
|
120
115
|
error: result.error,
|
|
121
116
|
backoff: !result.success && result.error?.includes("429")
|
|
122
117
|
};
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { H3Event } from 'h3';
|
|
2
|
+
import type { StaleCheckResult } from './checkStale.js';
|
|
2
3
|
export interface CronResult {
|
|
3
4
|
runId?: number | null;
|
|
5
|
+
stale?: StaleCheckResult;
|
|
6
|
+
sitemap?: {
|
|
7
|
+
seeded: number;
|
|
8
|
+
pruned: number;
|
|
9
|
+
};
|
|
4
10
|
index?: {
|
|
5
11
|
indexed: number;
|
|
6
12
|
remaining: number;
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { useEvent, useRuntimeConfig } from "nitropack/runtime";
|
|
2
|
-
import { cleanupOldCronRuns, completeCronRun, startCronRun } from "../db/queries.js";
|
|
2
|
+
import { cleanupOldCronRuns, completeCronRun, pruneStaleRoutes, seedRoutes, startCronRun } from "../db/queries.js";
|
|
3
|
+
import { logger } from "../logger.js";
|
|
3
4
|
import { batchIndexPages } from "./batchIndex.js";
|
|
5
|
+
import { checkAndHandleStale } from "./checkStale.js";
|
|
4
6
|
import { syncToIndexNow } from "./indexnow.js";
|
|
7
|
+
import { fetchSitemapUrls } from "./sitemap.js";
|
|
5
8
|
function getEvent(providedEvent) {
|
|
6
9
|
if (providedEvent)
|
|
7
10
|
return providedEvent;
|
|
@@ -20,8 +23,34 @@ export async function runCron(providedEvent, options) {
|
|
|
20
23
|
return {};
|
|
21
24
|
}
|
|
22
25
|
const config = useRuntimeConfig()["nuxt-ai-ready"];
|
|
26
|
+
const debug = config.debug;
|
|
27
|
+
const startTime = Date.now();
|
|
23
28
|
const results = {};
|
|
24
29
|
const allErrors = [];
|
|
30
|
+
if (debug) {
|
|
31
|
+
logger.info(`[cron] Starting cron run (batchSize: ${options?.batchSize ?? config.runtimeSync.batchSize}, indexNow: ${!!config.indexNowKey})`);
|
|
32
|
+
}
|
|
33
|
+
if (config.runtimeSync.enabled) {
|
|
34
|
+
results.stale = await checkAndHandleStale(event).catch((err) => {
|
|
35
|
+
console.warn("[ai-ready:cron] Stale check failed:", err.message);
|
|
36
|
+
allErrors.push(`stale-check: ${err.message}`);
|
|
37
|
+
return { action: "none", dbCount: 0, reason: err.message };
|
|
38
|
+
});
|
|
39
|
+
if (debug && results.stale) {
|
|
40
|
+
logger.info(`[cron] Stale check: ${results.stale.action} (db: ${results.stale.dbCount}, dump: ${results.stale.dumpCount ?? "n/a"})`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (config.runtimeSync.enabled) {
|
|
44
|
+
const sitemapResult = await seedFromSitemap(event, config, debug).catch((err) => {
|
|
45
|
+
console.warn("[ai-ready:cron] Sitemap seeding failed:", err.message);
|
|
46
|
+
allErrors.push(`sitemap: ${err.message}`);
|
|
47
|
+
return { seeded: 0, pruned: 0 };
|
|
48
|
+
});
|
|
49
|
+
results.sitemap = sitemapResult;
|
|
50
|
+
if (debug && (sitemapResult.seeded > 0 || sitemapResult.pruned > 0)) {
|
|
51
|
+
logger.info(`[cron] Sitemap: seeded ${sitemapResult.seeded}, pruned ${sitemapResult.pruned}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
25
54
|
const runId = await startCronRun(event);
|
|
26
55
|
results.runId = runId;
|
|
27
56
|
if (config.runtimeSync.enabled) {
|
|
@@ -39,6 +68,9 @@ export async function runCron(providedEvent, options) {
|
|
|
39
68
|
if (indexResult.errors.length > 0) {
|
|
40
69
|
allErrors.push(...indexResult.errors);
|
|
41
70
|
}
|
|
71
|
+
if (debug) {
|
|
72
|
+
logger.info(`[cron] Index: ${indexResult.indexed} pages (${indexResult.remaining} remaining${indexResult.errors.length > 0 ? `, ${indexResult.errors.length} errors` : ""})`);
|
|
73
|
+
}
|
|
42
74
|
}
|
|
43
75
|
if (config.indexNowKey) {
|
|
44
76
|
const indexNowResult = await syncToIndexNow(event, 100).catch((err) => {
|
|
@@ -53,6 +85,10 @@ export async function runCron(providedEvent, options) {
|
|
|
53
85
|
if (indexNowResult.error) {
|
|
54
86
|
allErrors.push(`IndexNow: ${indexNowResult.error}`);
|
|
55
87
|
}
|
|
88
|
+
if (debug) {
|
|
89
|
+
const status = indexNowResult.error ? `error: ${indexNowResult.error}` : `${indexNowResult.submitted} submitted (${indexNowResult.remaining} remaining)`;
|
|
90
|
+
logger.info(`[cron] IndexNow: ${status}`);
|
|
91
|
+
}
|
|
56
92
|
}
|
|
57
93
|
if (runId) {
|
|
58
94
|
await completeCronRun(event, runId, {
|
|
@@ -64,5 +100,39 @@ export async function runCron(providedEvent, options) {
|
|
|
64
100
|
});
|
|
65
101
|
await cleanupOldCronRuns(event, 50);
|
|
66
102
|
}
|
|
103
|
+
if (debug) {
|
|
104
|
+
const duration = Date.now() - startTime;
|
|
105
|
+
const parts = [];
|
|
106
|
+
if (results.stale?.action !== "none")
|
|
107
|
+
parts.push(results.stale?.action);
|
|
108
|
+
if (results.index?.indexed)
|
|
109
|
+
parts.push(`${results.index.indexed} indexed`);
|
|
110
|
+
if (results.indexNow?.submitted)
|
|
111
|
+
parts.push(`${results.indexNow.submitted} submitted to IndexNow`);
|
|
112
|
+
if (allErrors.length > 0)
|
|
113
|
+
parts.push(`${allErrors.length} errors`);
|
|
114
|
+
logger.info(`[cron] Complete in ${duration}ms${parts.length > 0 ? `: ${parts.join(", ")}` : ""}`);
|
|
115
|
+
}
|
|
67
116
|
return results;
|
|
68
117
|
}
|
|
118
|
+
async function seedFromSitemap(event, config, debug) {
|
|
119
|
+
const { pruneTtl } = config.runtimeSync;
|
|
120
|
+
const urls = await fetchSitemapUrls(event);
|
|
121
|
+
if (urls.length === 0) {
|
|
122
|
+
if (debug)
|
|
123
|
+
logger.info("[cron] Sitemap: no URLs found");
|
|
124
|
+
return { seeded: 0, pruned: 0 };
|
|
125
|
+
}
|
|
126
|
+
if (debug)
|
|
127
|
+
logger.info(`[cron] Sitemap: found ${urls.length} URLs`);
|
|
128
|
+
const routes = urls.map((u) => {
|
|
129
|
+
const url = new URL(u.loc);
|
|
130
|
+
return url.pathname;
|
|
131
|
+
}).filter((route) => !route.includes("."));
|
|
132
|
+
const seeded = await seedRoutes(event, routes);
|
|
133
|
+
let pruned = 0;
|
|
134
|
+
if (pruneTtl > 0) {
|
|
135
|
+
pruned = await pruneStaleRoutes(event, pruneTtl);
|
|
136
|
+
}
|
|
137
|
+
return { seeded, pruned };
|
|
138
|
+
}
|
package/package.json
CHANGED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { defineNitroPlugin, useRuntimeConfig } from "nitropack/runtime";
|
|
2
|
-
import { getSitemapSeededAt, pruneStaleRoutes, seedRoutes, setSitemapSeededAt } from "../db/queries.js";
|
|
3
|
-
import { logger } from "../logger.js";
|
|
4
|
-
import { fetchSitemapUrls } from "../utils/sitemap.js";
|
|
5
|
-
let seeding = null;
|
|
6
|
-
export default defineNitroPlugin((nitro) => {
|
|
7
|
-
if (import.meta.prerender)
|
|
8
|
-
return;
|
|
9
|
-
nitro.hooks.hook("request", async (event) => {
|
|
10
|
-
if (!seeding) {
|
|
11
|
-
seeding = seedFromSitemap(event).catch((err) => {
|
|
12
|
-
if (err.message?.includes("D1 binding") || err.message?.includes("[d1]")) {
|
|
13
|
-
const config = useRuntimeConfig()["nuxt-ai-ready"];
|
|
14
|
-
if (config.debug) {
|
|
15
|
-
const debugInfo = {
|
|
16
|
-
hasGlobalEnv: !!globalThis.__env__,
|
|
17
|
-
globalEnvKeys: globalThis.__env__ ? Object.keys(globalThis.__env__) : [],
|
|
18
|
-
hasCloudflareContext: !!event.context?.cloudflare,
|
|
19
|
-
error: err.message
|
|
20
|
-
};
|
|
21
|
-
logger.info("[sitemap-seeder] D1 binding debug:", JSON.stringify(debugInfo, null, 2));
|
|
22
|
-
}
|
|
23
|
-
logger.info("[sitemap-seeder] Skipping runtime seeding - using static pages data");
|
|
24
|
-
} else {
|
|
25
|
-
logger.error("[sitemap-seeder] Failed to seed:", err);
|
|
26
|
-
}
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
});
|
|
30
|
-
});
|
|
31
|
-
async function seedFromSitemap(event) {
|
|
32
|
-
const config = useRuntimeConfig()["nuxt-ai-ready"];
|
|
33
|
-
const { ttl, pruneTtl } = config.runtimeSync;
|
|
34
|
-
const seededAt = await getSitemapSeededAt(event);
|
|
35
|
-
if (seededAt && ttl > 0) {
|
|
36
|
-
const age = (Date.now() - seededAt) / 1e3;
|
|
37
|
-
if (age < ttl) {
|
|
38
|
-
logger.debug(`[sitemap-seeder] Sitemap fresh (${Math.round(age)}s old), skipping`);
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
const urls = await fetchSitemapUrls(event);
|
|
43
|
-
if (urls.length === 0) {
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
const routes = urls.map((u) => {
|
|
47
|
-
const url = new URL(u.loc);
|
|
48
|
-
return url.pathname;
|
|
49
|
-
}).filter((route) => !route.includes("."));
|
|
50
|
-
await seedRoutes(event, routes);
|
|
51
|
-
await setSitemapSeededAt(event, Date.now());
|
|
52
|
-
logger.info(`[sitemap-seeder] Seeded ${routes.length} routes from sitemap`);
|
|
53
|
-
if (pruneTtl > 0) {
|
|
54
|
-
const pruned = await pruneStaleRoutes(event, pruneTtl);
|
|
55
|
-
if (pruned > 0)
|
|
56
|
-
logger.info(`[sitemap-seeder] Pruned ${pruned} stale routes`);
|
|
57
|
-
}
|
|
58
|
-
}
|