nuxt-ai-ready 0.7.16 → 0.8.0
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 +137 -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.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
|
@@ -67,7 +67,121 @@ function hookNuxtSeoProLicense() {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
const INDEXNOW_HOSTS = ["api.indexnow.org", "www.bing.com"];
|
|
71
|
+
function getIndexNowEndpoints() {
|
|
72
|
+
const testEndpoint = process.env.INDEXNOW_TEST_ENDPOINT;
|
|
73
|
+
if (testEndpoint) {
|
|
74
|
+
return [testEndpoint];
|
|
75
|
+
}
|
|
76
|
+
return INDEXNOW_HOSTS.map((host) => `https://${host}/indexnow`);
|
|
77
|
+
}
|
|
78
|
+
function buildIndexNowBody(routes, key, siteUrl) {
|
|
79
|
+
const urlList = routes.map(
|
|
80
|
+
(route) => route.startsWith("http") ? route : `${siteUrl}${route}`
|
|
81
|
+
);
|
|
82
|
+
return {
|
|
83
|
+
host: new URL(siteUrl).host,
|
|
84
|
+
key,
|
|
85
|
+
keyLocation: `${siteUrl}/${key}.txt`,
|
|
86
|
+
urlList
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
async function submitToIndexNow(routes, key, siteUrl, options) {
|
|
90
|
+
if (!siteUrl) {
|
|
91
|
+
return { success: false, error: "Site URL not configured" };
|
|
92
|
+
}
|
|
93
|
+
const fetchFn = options?.fetchFn ?? globalThis.fetch;
|
|
94
|
+
const log = options?.logger;
|
|
95
|
+
const body = buildIndexNowBody(routes, key, siteUrl);
|
|
96
|
+
const endpoints = getIndexNowEndpoints();
|
|
97
|
+
let lastError;
|
|
98
|
+
for (const endpoint of endpoints) {
|
|
99
|
+
log?.debug(`[indexnow] Submitting ${body.urlList.length} URLs to ${endpoint}`);
|
|
100
|
+
const response = await fetchFn(endpoint, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: { "Content-Type": "application/json" },
|
|
103
|
+
body: JSON.stringify(body)
|
|
104
|
+
}).then((r) => r.ok ? { ok: true } : { error: `HTTP ${r.status}` }).catch((err) => ({ error: err.message }));
|
|
105
|
+
if ("error" in response) {
|
|
106
|
+
lastError = response.error;
|
|
107
|
+
if (lastError?.includes("429")) {
|
|
108
|
+
log?.warn(`[indexnow] Rate limited on ${endpoint}, trying fallback...`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
log?.warn(`[indexnow] Submission failed on ${endpoint}: ${lastError}`);
|
|
112
|
+
return { success: false, error: lastError, host: endpoint };
|
|
113
|
+
}
|
|
114
|
+
log?.debug(`[indexnow] Successfully submitted ${body.urlList.length} URLs via ${endpoint}`);
|
|
115
|
+
return { success: true, host: endpoint };
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
success: false,
|
|
119
|
+
error: lastError || "All endpoints rate limited",
|
|
120
|
+
host: endpoints[endpoints.length - 1]
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function comparePageHashes(currentPages, prevMeta) {
|
|
124
|
+
if (!prevMeta?.pages) {
|
|
125
|
+
return { changed: [], added: [], removed: [] };
|
|
126
|
+
}
|
|
127
|
+
const prevHashes = new Map(prevMeta.pages.map((p) => [p.route, p.hash]));
|
|
128
|
+
const currentRoutes = new Set(currentPages.map((p) => p.route));
|
|
129
|
+
const changed = [];
|
|
130
|
+
const added = [];
|
|
131
|
+
for (const page of currentPages) {
|
|
132
|
+
const prevHash = prevHashes.get(page.route);
|
|
133
|
+
if (!prevHash) {
|
|
134
|
+
added.push(page.route);
|
|
135
|
+
} else if (prevHash !== page.hash) {
|
|
136
|
+
changed.push(page.route);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const removed = [];
|
|
140
|
+
for (const route of prevHashes.keys()) {
|
|
141
|
+
if (!currentRoutes.has(route)) {
|
|
142
|
+
removed.push(route);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { changed, added, removed };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function fetchPreviousMeta(siteUrl, indexNowKey) {
|
|
149
|
+
const metaUrl = `${siteUrl}/__ai-ready/pages.meta.json`;
|
|
150
|
+
logger.debug(`[indexnow] Fetching previous meta from ${metaUrl}`);
|
|
151
|
+
const prevMeta = await fetch(metaUrl).then((r) => r.ok ? r.json() : null).catch(() => null);
|
|
152
|
+
if (!prevMeta?.pages) {
|
|
153
|
+
logger.info("[indexnow] First deploy or no previous meta - skipping IndexNow");
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const keyUrl = `${siteUrl}/${indexNowKey}.txt`;
|
|
157
|
+
const keyLive = await fetch(keyUrl).then((r) => r.ok).catch(() => false);
|
|
158
|
+
if (!keyLive) {
|
|
159
|
+
logger.info("[indexnow] Key file not live yet - skipping IndexNow");
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
return prevMeta;
|
|
163
|
+
}
|
|
164
|
+
async function handleStaticIndexNow(currentPages, siteUrl, indexNowKey, prevMeta) {
|
|
165
|
+
const { changed, added } = comparePageHashes(currentPages, prevMeta);
|
|
166
|
+
const totalChanged = changed.length + added.length;
|
|
167
|
+
if (totalChanged === 0) {
|
|
168
|
+
logger.debug("[indexnow] No content changes detected");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
logger.info(`[indexnow] Submitting ${totalChanged} changed pages (${changed.length} modified, ${added.length} new)`);
|
|
172
|
+
const result = await submitToIndexNow(
|
|
173
|
+
[...changed, ...added],
|
|
174
|
+
indexNowKey,
|
|
175
|
+
siteUrl,
|
|
176
|
+
{ logger }
|
|
177
|
+
);
|
|
178
|
+
if (result.success) {
|
|
179
|
+
logger.info(`[indexnow] Successfully notified search engines of ${totalChanged} changes`);
|
|
180
|
+
} else {
|
|
181
|
+
logger.warn(`[indexnow] Failed to submit: ${result.error}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function createCrawlerState(dbPath, llmsFullTxtPath, siteInfo, llmsTxtConfig, indexNowKey) {
|
|
71
185
|
return {
|
|
72
186
|
prerenderedRoutes: /* @__PURE__ */ new Set(),
|
|
73
187
|
errorRoutes: /* @__PURE__ */ new Set(),
|
|
@@ -76,7 +190,8 @@ function createCrawlerState(dbPath, llmsFullTxtPath, siteInfo, llmsTxtConfig) {
|
|
|
76
190
|
dbPath,
|
|
77
191
|
llmsFullTxtPath,
|
|
78
192
|
siteInfo,
|
|
79
|
-
llmsTxtConfig
|
|
193
|
+
llmsTxtConfig,
|
|
194
|
+
indexNowKey
|
|
80
195
|
};
|
|
81
196
|
}
|
|
82
197
|
async function initCrawler(state) {
|
|
@@ -227,11 +342,11 @@ async function prerenderRoute(nitro, route) {
|
|
|
227
342
|
nitro._prerenderedRoutes.push(_route);
|
|
228
343
|
return stat(filePath);
|
|
229
344
|
}
|
|
230
|
-
function setupPrerenderHandler(dbPath, siteInfo, llmsTxtConfig) {
|
|
345
|
+
function setupPrerenderHandler(dbPath, siteInfo, llmsTxtConfig, indexNowKey) {
|
|
231
346
|
const nuxt = useNuxt();
|
|
232
347
|
nuxt.hooks.hook("nitro:init", async (nitro) => {
|
|
233
348
|
const llmsFullTxtPath = join(nitro.options.output.publicDir, "llms-full.txt");
|
|
234
|
-
const state = createCrawlerState(dbPath, llmsFullTxtPath, siteInfo, llmsTxtConfig);
|
|
349
|
+
const state = createCrawlerState(dbPath, llmsFullTxtPath, siteInfo, llmsTxtConfig, indexNowKey);
|
|
235
350
|
let initPromise = null;
|
|
236
351
|
nitro.hooks.hook("prerender:generate", async (route) => {
|
|
237
352
|
if (route.error) {
|
|
@@ -294,6 +409,23 @@ function setupPrerenderHandler(dbPath, siteInfo, llmsTxtConfig) {
|
|
|
294
409
|
const dumpPath = join(publicDataDir, "pages.dump");
|
|
295
410
|
await writeFile(dumpPath, dumpData, "utf-8");
|
|
296
411
|
logger.debug(`Created database dump at __ai-ready/pages.dump (${(dumpData.length / 1024).toFixed(1)}kb compressed)`);
|
|
412
|
+
const pageHashes = pages.filter((p) => p.contentHash).map((p) => ({ route: p.route, hash: p.contentHash }));
|
|
413
|
+
let prevMeta = null;
|
|
414
|
+
if (state.indexNowKey && state.siteInfo?.url) {
|
|
415
|
+
prevMeta = await fetchPreviousMeta(state.siteInfo.url, state.indexNowKey);
|
|
416
|
+
}
|
|
417
|
+
const buildId = Date.now().toString(36);
|
|
418
|
+
const metaContent = JSON.stringify({
|
|
419
|
+
buildId,
|
|
420
|
+
pageCount: pages.length,
|
|
421
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
422
|
+
pages: pageHashes
|
|
423
|
+
});
|
|
424
|
+
await writeFile(join(publicDataDir, "pages.meta.json"), metaContent, "utf-8");
|
|
425
|
+
logger.debug(`Wrote build metadata: buildId=${buildId}, ${pageHashes.length} page hashes`);
|
|
426
|
+
if (state.indexNowKey && state.siteInfo?.url && prevMeta) {
|
|
427
|
+
await handleStaticIndexNow(pageHashes, state.siteInfo.url, state.indexNowKey, prevMeta);
|
|
428
|
+
}
|
|
297
429
|
}
|
|
298
430
|
const llmsStats = await prerenderRoute(nitro, "/llms.txt");
|
|
299
431
|
const llmsFullStats = await stat(state.llmsFullTxtPath);
|
|
@@ -666,9 +798,6 @@ export const errorRoutes = []`;
|
|
|
666
798
|
runtimeSyncSecret: config.runtimeSyncSecret,
|
|
667
799
|
indexNowKey
|
|
668
800
|
};
|
|
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
801
|
addServerHandler({
|
|
673
802
|
middleware: true,
|
|
674
803
|
handler: resolve("./runtime/server/middleware/markdown.prerender")
|
|
@@ -721,7 +850,7 @@ export const errorRoutes = []`;
|
|
|
721
850
|
name: siteConfig.name,
|
|
722
851
|
url: siteConfig.url,
|
|
723
852
|
description: siteConfig.description
|
|
724
|
-
}, mergedLlmsTxt);
|
|
853
|
+
}, mergedLlmsTxt, indexNowKey);
|
|
725
854
|
}
|
|
726
855
|
nuxt.options.nitro.routeRules = nuxt.options.nitro.routeRules || {};
|
|
727
856
|
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
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { H3Event } from 'h3';
|
|
2
|
+
export type { BuildMeta, IndexNowSubmitResult, PageHashMeta } from '../../../shared/indexnow.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>;
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { getSiteConfig } from "#site-config/server/composables";
|
|
2
2
|
import { useRuntimeConfig } from "nitropack/runtime";
|
|
3
|
+
import { submitToIndexNow as submitToIndexNowShared } from "../../../shared/indexnow";
|
|
3
4
|
import { useDatabase } from "../db/index.js";
|
|
4
5
|
import {
|
|
6
|
+
batchIndexNowUpdate,
|
|
5
7
|
countPagesNeedingIndexNowSync,
|
|
6
8
|
getPagesNeedingIndexNowSync,
|
|
7
9
|
logIndexNowSubmission,
|
|
8
|
-
markIndexNowSynced,
|
|
9
10
|
updateIndexNowStats
|
|
10
11
|
} from "../db/queries.js";
|
|
11
12
|
import { logger } from "../logger.js";
|
|
@@ -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
|
-
}
|