@consilioweb/payload-seo-analyzer 1.12.0 → 1.14.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/README.md +1 -0
- package/dist/client.cjs +374 -150
- package/dist/client.js +374 -150
- package/dist/index.cjs +152 -14
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +152 -14
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -1683,9 +1683,9 @@ function analyzeDoc(doc, collection, seoConfig) {
|
|
|
1683
1683
|
};
|
|
1684
1684
|
}
|
|
1685
1685
|
var CACHE_KEY = "audit";
|
|
1686
|
-
var
|
|
1687
|
-
async function buildAuditCache(payload, collections, globals, seoConfig) {
|
|
1688
|
-
const { config: mergedConfig, ignoredSlugs } = await loadMergedConfig(payload, seoConfig);
|
|
1686
|
+
var auditBuildsInFlight = /* @__PURE__ */ new Set();
|
|
1687
|
+
async function buildAuditCache(payload, collections, globals, seoConfig, reqLocale) {
|
|
1688
|
+
const { config: mergedConfig, ignoredSlugs } = await loadMergedConfig(payload, seoConfig, { reqLocale });
|
|
1689
1689
|
const BATCH_SIZE = Math.min(100, Math.max(1, parseInt(process.env.SEO_AUDIT_BATCH_SIZE || "15", 10) || 15));
|
|
1690
1690
|
const MAX_DOCS2 = Math.max(1, parseInt(process.env.SEO_AUDIT_MAX_DOCS || "1500", 10) || 1500);
|
|
1691
1691
|
const allResults = [];
|
|
@@ -1793,15 +1793,15 @@ async function buildAuditCache(payload, collections, globals, seoConfig) {
|
|
|
1793
1793
|
};
|
|
1794
1794
|
return { enrichedResults, stats, capped };
|
|
1795
1795
|
}
|
|
1796
|
-
function ensureAuditBuild(payload, collections, globals, seoConfig) {
|
|
1797
|
-
if (
|
|
1798
|
-
|
|
1799
|
-
void buildAuditCache(payload, collections, globals, seoConfig).then((result) => {
|
|
1800
|
-
seoCache.set(
|
|
1796
|
+
function ensureAuditBuild(payload, collections, globals, seoConfig, cacheKey, reqLocale) {
|
|
1797
|
+
if (auditBuildsInFlight.has(cacheKey)) return;
|
|
1798
|
+
auditBuildsInFlight.add(cacheKey);
|
|
1799
|
+
void buildAuditCache(payload, collections, globals, seoConfig, reqLocale).then((result) => {
|
|
1800
|
+
seoCache.set(cacheKey, result);
|
|
1801
1801
|
}).catch((e) => {
|
|
1802
1802
|
payload.logger.error(`[seo] audit build failed: ${e instanceof Error ? e.message : "unknown"}`);
|
|
1803
1803
|
}).finally(() => {
|
|
1804
|
-
|
|
1804
|
+
auditBuildsInFlight.delete(cacheKey);
|
|
1805
1805
|
});
|
|
1806
1806
|
}
|
|
1807
1807
|
function createAuditHandler(collections, seoConfig, globals = []) {
|
|
@@ -1814,12 +1814,14 @@ function createAuditHandler(collections, seoConfig, globals = []) {
|
|
|
1814
1814
|
const page = Math.max(1, parseInt(url.searchParams.get("page") || "1", 10));
|
|
1815
1815
|
const limit = Math.min(500, Math.max(1, parseInt(url.searchParams.get("limit") || "300", 10)));
|
|
1816
1816
|
const noCache = url.searchParams.get("nocache") === "1";
|
|
1817
|
-
|
|
1818
|
-
|
|
1817
|
+
const reqLocale = typeof req.locale === "string" && req.locale ? req.locale : void 0;
|
|
1818
|
+
const cacheKey = reqLocale ? `${CACHE_KEY}:${reqLocale}` : CACHE_KEY;
|
|
1819
|
+
if (noCache && !auditBuildsInFlight.has(cacheKey)) {
|
|
1820
|
+
seoCache.invalidateKey(cacheKey);
|
|
1819
1821
|
}
|
|
1820
|
-
const cached = seoCache.get(
|
|
1822
|
+
const cached = seoCache.get(cacheKey);
|
|
1821
1823
|
if (!cached) {
|
|
1822
|
-
ensureAuditBuild(req.payload, collections, globals, seoConfig);
|
|
1824
|
+
ensureAuditBuild(req.payload, collections, globals, seoConfig, cacheKey, reqLocale);
|
|
1823
1825
|
return Response.json(
|
|
1824
1826
|
{ building: true, results: [], stats: null },
|
|
1825
1827
|
{ status: 202, headers: { "Cache-Control": "no-store" } }
|
|
@@ -7392,6 +7394,137 @@ function createRankHistoryHandler() {
|
|
|
7392
7394
|
};
|
|
7393
7395
|
}
|
|
7394
7396
|
|
|
7397
|
+
// src/endpoints/ctrOpportunities.ts
|
|
7398
|
+
function expectedCtrForPosition(pos) {
|
|
7399
|
+
const table = {
|
|
7400
|
+
1: 0.28,
|
|
7401
|
+
2: 0.15,
|
|
7402
|
+
3: 0.1,
|
|
7403
|
+
4: 0.07,
|
|
7404
|
+
5: 0.055,
|
|
7405
|
+
6: 0.045,
|
|
7406
|
+
7: 0.035,
|
|
7407
|
+
8: 0.03,
|
|
7408
|
+
9: 0.025,
|
|
7409
|
+
10: 0.022
|
|
7410
|
+
};
|
|
7411
|
+
if (pos <= 1) return table[1];
|
|
7412
|
+
if (pos <= 10) {
|
|
7413
|
+
const lo = Math.floor(pos);
|
|
7414
|
+
const hi = Math.ceil(pos);
|
|
7415
|
+
const a = table[lo] ?? 0.02;
|
|
7416
|
+
const b = table[hi] ?? 0.02;
|
|
7417
|
+
return a + (b - a) * (pos - lo);
|
|
7418
|
+
}
|
|
7419
|
+
if (pos <= 20) return 0.012;
|
|
7420
|
+
return 5e-3;
|
|
7421
|
+
}
|
|
7422
|
+
function rankCtrOpportunities(rows, opts = {}) {
|
|
7423
|
+
const minImpressions = opts.minImpressions ?? 50;
|
|
7424
|
+
const maxPosition = opts.maxPosition ?? 20;
|
|
7425
|
+
const out = [];
|
|
7426
|
+
for (const r of rows) {
|
|
7427
|
+
const url = r.keys?.[0];
|
|
7428
|
+
if (!url) continue;
|
|
7429
|
+
if (r.impressions < minImpressions) continue;
|
|
7430
|
+
if (r.position > maxPosition) continue;
|
|
7431
|
+
const expectedCtr = expectedCtrForPosition(r.position);
|
|
7432
|
+
const gap = expectedCtr - r.ctr;
|
|
7433
|
+
if (gap <= 0) continue;
|
|
7434
|
+
const potentialClicks = Math.round(r.impressions * gap);
|
|
7435
|
+
if (potentialClicks < 1) continue;
|
|
7436
|
+
out.push({
|
|
7437
|
+
url,
|
|
7438
|
+
impressions: r.impressions,
|
|
7439
|
+
clicks: r.clicks,
|
|
7440
|
+
ctr: r.ctr,
|
|
7441
|
+
position: Math.round(r.position * 10) / 10,
|
|
7442
|
+
expectedCtr: Math.round(expectedCtr * 1e3) / 1e3,
|
|
7443
|
+
potentialClicks
|
|
7444
|
+
});
|
|
7445
|
+
}
|
|
7446
|
+
out.sort((a, b) => b.potentialClicks - a.potentialClicks);
|
|
7447
|
+
return out;
|
|
7448
|
+
}
|
|
7449
|
+
async function resolveDoc(payload, url, targetCollections) {
|
|
7450
|
+
let path;
|
|
7451
|
+
try {
|
|
7452
|
+
path = new URL(url).pathname;
|
|
7453
|
+
} catch {
|
|
7454
|
+
return null;
|
|
7455
|
+
}
|
|
7456
|
+
const segments = path.split("/").filter(Boolean);
|
|
7457
|
+
const slug = segments.length === 0 ? "home" : segments[segments.length - 1];
|
|
7458
|
+
for (const collection of targetCollections) {
|
|
7459
|
+
try {
|
|
7460
|
+
const res = await payload.find({
|
|
7461
|
+
collection,
|
|
7462
|
+
where: { slug: { equals: slug } },
|
|
7463
|
+
limit: 1,
|
|
7464
|
+
depth: 0,
|
|
7465
|
+
overrideAccess: true
|
|
7466
|
+
});
|
|
7467
|
+
if (res.docs.length > 0) {
|
|
7468
|
+
return { collection, id: String(res.docs[0].id) };
|
|
7469
|
+
}
|
|
7470
|
+
} catch {
|
|
7471
|
+
}
|
|
7472
|
+
}
|
|
7473
|
+
return null;
|
|
7474
|
+
}
|
|
7475
|
+
function createCtrOpportunitiesHandler(basePath, targetCollections, seoConfig) {
|
|
7476
|
+
return async (req) => {
|
|
7477
|
+
try {
|
|
7478
|
+
if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
7479
|
+
const cfg = getGscOAuthConfig(basePath, seoConfig);
|
|
7480
|
+
if (!cfg) return Response.json({ error: "GSC OAuth not configured." }, { status: 400 });
|
|
7481
|
+
const authDoc = await getOrCreateGscAuthDoc(req.payload);
|
|
7482
|
+
if (!authDoc.refreshTokenEnc) {
|
|
7483
|
+
return Response.json({ error: "Not connected to Google Search Console." }, { status: 409 });
|
|
7484
|
+
}
|
|
7485
|
+
let accessToken;
|
|
7486
|
+
try {
|
|
7487
|
+
accessToken = await getGscAccessToken(req.payload, cfg, authDoc);
|
|
7488
|
+
} catch (e) {
|
|
7489
|
+
const code = e instanceof Error ? e.message : "refresh_failed";
|
|
7490
|
+
const status = code === "decrypt_failed" ? 409 : 502;
|
|
7491
|
+
return Response.json({ error: "Could not refresh GSC access token." }, { status });
|
|
7492
|
+
}
|
|
7493
|
+
const url = new URL(req.url);
|
|
7494
|
+
const minImpressions = Math.max(1, parseInt(url.searchParams.get("minImpressions") || "50", 10) || 50);
|
|
7495
|
+
const property = authDoc.propertyUrl || cfg.siteUrl;
|
|
7496
|
+
const end = new Date(Date.now() - 2 * 864e5);
|
|
7497
|
+
const start = new Date(end.getTime() - 27 * 864e5);
|
|
7498
|
+
const endDate = end.toISOString().slice(0, 10);
|
|
7499
|
+
const startDate = start.toISOString().slice(0, 10);
|
|
7500
|
+
let rows;
|
|
7501
|
+
try {
|
|
7502
|
+
rows = await queryGscSearchAnalytics(accessToken, property, {
|
|
7503
|
+
startDate,
|
|
7504
|
+
endDate,
|
|
7505
|
+
dimensions: ["page"],
|
|
7506
|
+
rowLimit: 250
|
|
7507
|
+
});
|
|
7508
|
+
} catch (e) {
|
|
7509
|
+
return Response.json({ error: e instanceof Error ? e.message : "GSC query failed" }, { status: 502 });
|
|
7510
|
+
}
|
|
7511
|
+
const opportunities = rankCtrOpportunities(rows, { minImpressions });
|
|
7512
|
+
const top = opportunities.slice(0, 50);
|
|
7513
|
+
const resolved = await Promise.all(
|
|
7514
|
+
top.map(async (o) => ({ ...o, doc: await resolveDoc(req.payload, o.url, targetCollections) }))
|
|
7515
|
+
);
|
|
7516
|
+
return Response.json(
|
|
7517
|
+
{ property, startDate, endDate, count: opportunities.length, opportunities: resolved },
|
|
7518
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
7519
|
+
);
|
|
7520
|
+
} catch (error) {
|
|
7521
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
7522
|
+
req.payload.logger.error(`[seo] ctr-opportunities error: ${message}`);
|
|
7523
|
+
return Response.json({ error: message }, { status: 500 });
|
|
7524
|
+
}
|
|
7525
|
+
};
|
|
7526
|
+
}
|
|
7527
|
+
|
|
7395
7528
|
// src/rateLimiter.ts
|
|
7396
7529
|
function createRateLimiter(maxRequests, windowMs) {
|
|
7397
7530
|
const store = /* @__PURE__ */ new Map();
|
|
@@ -9023,6 +9156,8 @@ var fr = {
|
|
|
9023
9156
|
bulkOptimizeMeta: "Optimiser m\xE9ta (IA)",
|
|
9024
9157
|
bulkOptimizing: "Analyse\u2026",
|
|
9025
9158
|
bulkConfirm: "Confirmer ?",
|
|
9159
|
+
optimizeSite: "Optimiser le site",
|
|
9160
|
+
optimizeSiteHint: "Cible automatiquement les pages \xE0 probl\xE8me (m\xE9ta manquante, sans mot-cl\xE9, score faible) \u2192 aper\xE7u \u2192 appliquer",
|
|
9026
9161
|
bulkPreviewTitle: "Corrections m\xE9ta propos\xE9es",
|
|
9027
9162
|
bulkApply: "Appliquer",
|
|
9028
9163
|
bulkApplying: "Application\u2026",
|
|
@@ -9625,6 +9760,8 @@ var en = {
|
|
|
9625
9760
|
bulkOptimizeMeta: "Optimize meta (AI)",
|
|
9626
9761
|
bulkOptimizing: "Analyzing\u2026",
|
|
9627
9762
|
bulkConfirm: "Confirm?",
|
|
9763
|
+
optimizeSite: "Optimize site",
|
|
9764
|
+
optimizeSiteHint: "Auto-targets pages that need work (missing meta, no keyword, low score) \u2192 preview \u2192 apply",
|
|
9628
9765
|
bulkPreviewTitle: "Proposed meta corrections",
|
|
9629
9766
|
bulkApply: "Apply",
|
|
9630
9767
|
bulkApplying: "Applying\u2026",
|
|
@@ -10518,7 +10655,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
10518
10655
|
{ path: `${basePath}/gsc/data`, method: "get", handler: withRateLimit(createGscDataHandler(basePath, seoConfig)) },
|
|
10519
10656
|
{ path: `${basePath}/gsc/disconnect`, method: "post", handler: createGscDisconnectHandler() },
|
|
10520
10657
|
{ path: `${basePath}/rank-snapshot`, method: "post", handler: withRateLimit(createRankSnapshotHandler(basePath, seoConfig)) },
|
|
10521
|
-
{ path: `${basePath}/rank-history`, method: "get", handler: createRankHistoryHandler() }
|
|
10658
|
+
{ path: `${basePath}/rank-history`, method: "get", handler: createRankHistoryHandler() },
|
|
10659
|
+
{ path: `${basePath}/ctr-opportunities`, method: "get", handler: createCtrOpportunitiesHandler(basePath, targetCollections, seoConfig) }
|
|
10522
10660
|
);
|
|
10523
10661
|
}
|
|
10524
10662
|
if (features.alerts) {
|
package/dist/index.d.cts
CHANGED
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1681,9 +1681,9 @@ function analyzeDoc(doc, collection, seoConfig) {
|
|
|
1681
1681
|
};
|
|
1682
1682
|
}
|
|
1683
1683
|
var CACHE_KEY = "audit";
|
|
1684
|
-
var
|
|
1685
|
-
async function buildAuditCache(payload, collections, globals, seoConfig) {
|
|
1686
|
-
const { config: mergedConfig, ignoredSlugs } = await loadMergedConfig(payload, seoConfig);
|
|
1684
|
+
var auditBuildsInFlight = /* @__PURE__ */ new Set();
|
|
1685
|
+
async function buildAuditCache(payload, collections, globals, seoConfig, reqLocale) {
|
|
1686
|
+
const { config: mergedConfig, ignoredSlugs } = await loadMergedConfig(payload, seoConfig, { reqLocale });
|
|
1687
1687
|
const BATCH_SIZE = Math.min(100, Math.max(1, parseInt(process.env.SEO_AUDIT_BATCH_SIZE || "15", 10) || 15));
|
|
1688
1688
|
const MAX_DOCS2 = Math.max(1, parseInt(process.env.SEO_AUDIT_MAX_DOCS || "1500", 10) || 1500);
|
|
1689
1689
|
const allResults = [];
|
|
@@ -1791,15 +1791,15 @@ async function buildAuditCache(payload, collections, globals, seoConfig) {
|
|
|
1791
1791
|
};
|
|
1792
1792
|
return { enrichedResults, stats, capped };
|
|
1793
1793
|
}
|
|
1794
|
-
function ensureAuditBuild(payload, collections, globals, seoConfig) {
|
|
1795
|
-
if (
|
|
1796
|
-
|
|
1797
|
-
void buildAuditCache(payload, collections, globals, seoConfig).then((result) => {
|
|
1798
|
-
seoCache.set(
|
|
1794
|
+
function ensureAuditBuild(payload, collections, globals, seoConfig, cacheKey, reqLocale) {
|
|
1795
|
+
if (auditBuildsInFlight.has(cacheKey)) return;
|
|
1796
|
+
auditBuildsInFlight.add(cacheKey);
|
|
1797
|
+
void buildAuditCache(payload, collections, globals, seoConfig, reqLocale).then((result) => {
|
|
1798
|
+
seoCache.set(cacheKey, result);
|
|
1799
1799
|
}).catch((e) => {
|
|
1800
1800
|
payload.logger.error(`[seo] audit build failed: ${e instanceof Error ? e.message : "unknown"}`);
|
|
1801
1801
|
}).finally(() => {
|
|
1802
|
-
|
|
1802
|
+
auditBuildsInFlight.delete(cacheKey);
|
|
1803
1803
|
});
|
|
1804
1804
|
}
|
|
1805
1805
|
function createAuditHandler(collections, seoConfig, globals = []) {
|
|
@@ -1812,12 +1812,14 @@ function createAuditHandler(collections, seoConfig, globals = []) {
|
|
|
1812
1812
|
const page = Math.max(1, parseInt(url.searchParams.get("page") || "1", 10));
|
|
1813
1813
|
const limit = Math.min(500, Math.max(1, parseInt(url.searchParams.get("limit") || "300", 10)));
|
|
1814
1814
|
const noCache = url.searchParams.get("nocache") === "1";
|
|
1815
|
-
|
|
1816
|
-
|
|
1815
|
+
const reqLocale = typeof req.locale === "string" && req.locale ? req.locale : void 0;
|
|
1816
|
+
const cacheKey = reqLocale ? `${CACHE_KEY}:${reqLocale}` : CACHE_KEY;
|
|
1817
|
+
if (noCache && !auditBuildsInFlight.has(cacheKey)) {
|
|
1818
|
+
seoCache.invalidateKey(cacheKey);
|
|
1817
1819
|
}
|
|
1818
|
-
const cached = seoCache.get(
|
|
1820
|
+
const cached = seoCache.get(cacheKey);
|
|
1819
1821
|
if (!cached) {
|
|
1820
|
-
ensureAuditBuild(req.payload, collections, globals, seoConfig);
|
|
1822
|
+
ensureAuditBuild(req.payload, collections, globals, seoConfig, cacheKey, reqLocale);
|
|
1821
1823
|
return Response.json(
|
|
1822
1824
|
{ building: true, results: [], stats: null },
|
|
1823
1825
|
{ status: 202, headers: { "Cache-Control": "no-store" } }
|
|
@@ -7390,6 +7392,137 @@ function createRankHistoryHandler() {
|
|
|
7390
7392
|
};
|
|
7391
7393
|
}
|
|
7392
7394
|
|
|
7395
|
+
// src/endpoints/ctrOpportunities.ts
|
|
7396
|
+
function expectedCtrForPosition(pos) {
|
|
7397
|
+
const table = {
|
|
7398
|
+
1: 0.28,
|
|
7399
|
+
2: 0.15,
|
|
7400
|
+
3: 0.1,
|
|
7401
|
+
4: 0.07,
|
|
7402
|
+
5: 0.055,
|
|
7403
|
+
6: 0.045,
|
|
7404
|
+
7: 0.035,
|
|
7405
|
+
8: 0.03,
|
|
7406
|
+
9: 0.025,
|
|
7407
|
+
10: 0.022
|
|
7408
|
+
};
|
|
7409
|
+
if (pos <= 1) return table[1];
|
|
7410
|
+
if (pos <= 10) {
|
|
7411
|
+
const lo = Math.floor(pos);
|
|
7412
|
+
const hi = Math.ceil(pos);
|
|
7413
|
+
const a = table[lo] ?? 0.02;
|
|
7414
|
+
const b = table[hi] ?? 0.02;
|
|
7415
|
+
return a + (b - a) * (pos - lo);
|
|
7416
|
+
}
|
|
7417
|
+
if (pos <= 20) return 0.012;
|
|
7418
|
+
return 5e-3;
|
|
7419
|
+
}
|
|
7420
|
+
function rankCtrOpportunities(rows, opts = {}) {
|
|
7421
|
+
const minImpressions = opts.minImpressions ?? 50;
|
|
7422
|
+
const maxPosition = opts.maxPosition ?? 20;
|
|
7423
|
+
const out = [];
|
|
7424
|
+
for (const r of rows) {
|
|
7425
|
+
const url = r.keys?.[0];
|
|
7426
|
+
if (!url) continue;
|
|
7427
|
+
if (r.impressions < minImpressions) continue;
|
|
7428
|
+
if (r.position > maxPosition) continue;
|
|
7429
|
+
const expectedCtr = expectedCtrForPosition(r.position);
|
|
7430
|
+
const gap = expectedCtr - r.ctr;
|
|
7431
|
+
if (gap <= 0) continue;
|
|
7432
|
+
const potentialClicks = Math.round(r.impressions * gap);
|
|
7433
|
+
if (potentialClicks < 1) continue;
|
|
7434
|
+
out.push({
|
|
7435
|
+
url,
|
|
7436
|
+
impressions: r.impressions,
|
|
7437
|
+
clicks: r.clicks,
|
|
7438
|
+
ctr: r.ctr,
|
|
7439
|
+
position: Math.round(r.position * 10) / 10,
|
|
7440
|
+
expectedCtr: Math.round(expectedCtr * 1e3) / 1e3,
|
|
7441
|
+
potentialClicks
|
|
7442
|
+
});
|
|
7443
|
+
}
|
|
7444
|
+
out.sort((a, b) => b.potentialClicks - a.potentialClicks);
|
|
7445
|
+
return out;
|
|
7446
|
+
}
|
|
7447
|
+
async function resolveDoc(payload, url, targetCollections) {
|
|
7448
|
+
let path;
|
|
7449
|
+
try {
|
|
7450
|
+
path = new URL(url).pathname;
|
|
7451
|
+
} catch {
|
|
7452
|
+
return null;
|
|
7453
|
+
}
|
|
7454
|
+
const segments = path.split("/").filter(Boolean);
|
|
7455
|
+
const slug = segments.length === 0 ? "home" : segments[segments.length - 1];
|
|
7456
|
+
for (const collection of targetCollections) {
|
|
7457
|
+
try {
|
|
7458
|
+
const res = await payload.find({
|
|
7459
|
+
collection,
|
|
7460
|
+
where: { slug: { equals: slug } },
|
|
7461
|
+
limit: 1,
|
|
7462
|
+
depth: 0,
|
|
7463
|
+
overrideAccess: true
|
|
7464
|
+
});
|
|
7465
|
+
if (res.docs.length > 0) {
|
|
7466
|
+
return { collection, id: String(res.docs[0].id) };
|
|
7467
|
+
}
|
|
7468
|
+
} catch {
|
|
7469
|
+
}
|
|
7470
|
+
}
|
|
7471
|
+
return null;
|
|
7472
|
+
}
|
|
7473
|
+
function createCtrOpportunitiesHandler(basePath, targetCollections, seoConfig) {
|
|
7474
|
+
return async (req) => {
|
|
7475
|
+
try {
|
|
7476
|
+
if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
7477
|
+
const cfg = getGscOAuthConfig(basePath, seoConfig);
|
|
7478
|
+
if (!cfg) return Response.json({ error: "GSC OAuth not configured." }, { status: 400 });
|
|
7479
|
+
const authDoc = await getOrCreateGscAuthDoc(req.payload);
|
|
7480
|
+
if (!authDoc.refreshTokenEnc) {
|
|
7481
|
+
return Response.json({ error: "Not connected to Google Search Console." }, { status: 409 });
|
|
7482
|
+
}
|
|
7483
|
+
let accessToken;
|
|
7484
|
+
try {
|
|
7485
|
+
accessToken = await getGscAccessToken(req.payload, cfg, authDoc);
|
|
7486
|
+
} catch (e) {
|
|
7487
|
+
const code = e instanceof Error ? e.message : "refresh_failed";
|
|
7488
|
+
const status = code === "decrypt_failed" ? 409 : 502;
|
|
7489
|
+
return Response.json({ error: "Could not refresh GSC access token." }, { status });
|
|
7490
|
+
}
|
|
7491
|
+
const url = new URL(req.url);
|
|
7492
|
+
const minImpressions = Math.max(1, parseInt(url.searchParams.get("minImpressions") || "50", 10) || 50);
|
|
7493
|
+
const property = authDoc.propertyUrl || cfg.siteUrl;
|
|
7494
|
+
const end = new Date(Date.now() - 2 * 864e5);
|
|
7495
|
+
const start = new Date(end.getTime() - 27 * 864e5);
|
|
7496
|
+
const endDate = end.toISOString().slice(0, 10);
|
|
7497
|
+
const startDate = start.toISOString().slice(0, 10);
|
|
7498
|
+
let rows;
|
|
7499
|
+
try {
|
|
7500
|
+
rows = await queryGscSearchAnalytics(accessToken, property, {
|
|
7501
|
+
startDate,
|
|
7502
|
+
endDate,
|
|
7503
|
+
dimensions: ["page"],
|
|
7504
|
+
rowLimit: 250
|
|
7505
|
+
});
|
|
7506
|
+
} catch (e) {
|
|
7507
|
+
return Response.json({ error: e instanceof Error ? e.message : "GSC query failed" }, { status: 502 });
|
|
7508
|
+
}
|
|
7509
|
+
const opportunities = rankCtrOpportunities(rows, { minImpressions });
|
|
7510
|
+
const top = opportunities.slice(0, 50);
|
|
7511
|
+
const resolved = await Promise.all(
|
|
7512
|
+
top.map(async (o) => ({ ...o, doc: await resolveDoc(req.payload, o.url, targetCollections) }))
|
|
7513
|
+
);
|
|
7514
|
+
return Response.json(
|
|
7515
|
+
{ property, startDate, endDate, count: opportunities.length, opportunities: resolved },
|
|
7516
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
7517
|
+
);
|
|
7518
|
+
} catch (error) {
|
|
7519
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
7520
|
+
req.payload.logger.error(`[seo] ctr-opportunities error: ${message}`);
|
|
7521
|
+
return Response.json({ error: message }, { status: 500 });
|
|
7522
|
+
}
|
|
7523
|
+
};
|
|
7524
|
+
}
|
|
7525
|
+
|
|
7393
7526
|
// src/rateLimiter.ts
|
|
7394
7527
|
function createRateLimiter(maxRequests, windowMs) {
|
|
7395
7528
|
const store = /* @__PURE__ */ new Map();
|
|
@@ -9021,6 +9154,8 @@ var fr = {
|
|
|
9021
9154
|
bulkOptimizeMeta: "Optimiser m\xE9ta (IA)",
|
|
9022
9155
|
bulkOptimizing: "Analyse\u2026",
|
|
9023
9156
|
bulkConfirm: "Confirmer ?",
|
|
9157
|
+
optimizeSite: "Optimiser le site",
|
|
9158
|
+
optimizeSiteHint: "Cible automatiquement les pages \xE0 probl\xE8me (m\xE9ta manquante, sans mot-cl\xE9, score faible) \u2192 aper\xE7u \u2192 appliquer",
|
|
9024
9159
|
bulkPreviewTitle: "Corrections m\xE9ta propos\xE9es",
|
|
9025
9160
|
bulkApply: "Appliquer",
|
|
9026
9161
|
bulkApplying: "Application\u2026",
|
|
@@ -9623,6 +9758,8 @@ var en = {
|
|
|
9623
9758
|
bulkOptimizeMeta: "Optimize meta (AI)",
|
|
9624
9759
|
bulkOptimizing: "Analyzing\u2026",
|
|
9625
9760
|
bulkConfirm: "Confirm?",
|
|
9761
|
+
optimizeSite: "Optimize site",
|
|
9762
|
+
optimizeSiteHint: "Auto-targets pages that need work (missing meta, no keyword, low score) \u2192 preview \u2192 apply",
|
|
9626
9763
|
bulkPreviewTitle: "Proposed meta corrections",
|
|
9627
9764
|
bulkApply: "Apply",
|
|
9628
9765
|
bulkApplying: "Applying\u2026",
|
|
@@ -10516,7 +10653,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
10516
10653
|
{ path: `${basePath}/gsc/data`, method: "get", handler: withRateLimit(createGscDataHandler(basePath, seoConfig)) },
|
|
10517
10654
|
{ path: `${basePath}/gsc/disconnect`, method: "post", handler: createGscDisconnectHandler() },
|
|
10518
10655
|
{ path: `${basePath}/rank-snapshot`, method: "post", handler: withRateLimit(createRankSnapshotHandler(basePath, seoConfig)) },
|
|
10519
|
-
{ path: `${basePath}/rank-history`, method: "get", handler: createRankHistoryHandler() }
|
|
10656
|
+
{ path: `${basePath}/rank-history`, method: "get", handler: createRankHistoryHandler() },
|
|
10657
|
+
{ path: `${basePath}/ctr-opportunities`, method: "get", handler: createCtrOpportunitiesHandler(basePath, targetCollections, seoConfig) }
|
|
10520
10658
|
);
|
|
10521
10659
|
}
|
|
10522
10660
|
if (features.alerts) {
|
package/package.json
CHANGED