seo 0.4.2 → 0.6.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/PRIVACY.md +39 -11
- package/README.md +8 -8
- package/dist/cli.js +85 -83
- package/dist/cli.js.map +1 -1
- package/dist/{dist-DBHFmJmr.js → dist-C_Ezkcm5.js} +1 -1
- package/dist/{dist-DV2Z-HIp.js → dist-nmppE37y.js} +85 -84
- package/dist/dist-nmppE37y.js.map +1 -0
- package/dist/index.d.ts +95 -45
- package/dist/index.js +625 -327
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +1720 -1196
- package/dist/mcp.js.map +1 -1
- package/dist/{package-D46BDwUk.js → package-CwLtPH6B.js} +2 -2
- package/dist/{package-D46BDwUk.js.map → package-CwLtPH6B.js.map} +1 -1
- package/package.json +2 -2
- package/skills/seo/SKILL.md +19 -18
- package/dist/dist-DV2Z-HIp.js.map +0 -1
package/dist/mcp.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
|
|
2
|
-
import { i as __toESM, n as load, r as __commonJSMin, t as version } from "./package-
|
|
2
|
+
import { i as __toESM, n as load, r as __commonJSMin, t as version } from "./package-CwLtPH6B.js";
|
|
3
3
|
import { createHash, randomUUID, sign } from "node:crypto";
|
|
4
4
|
import { ZodOptional, z } from "zod";
|
|
5
|
-
import { chmodSync, createReadStream, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
6
5
|
import { arch, homedir, platform, totalmem } from "node:os";
|
|
7
6
|
import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
7
|
+
import "open";
|
|
8
|
+
import { chmodSync, createReadStream, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
8
9
|
import lockfile from "proper-lockfile";
|
|
9
10
|
import "node:http";
|
|
10
|
-
import "open";
|
|
11
11
|
import { Headers as Headers$1, fetch as fetch$1 } from "undici";
|
|
12
12
|
import Libsql from "libsql";
|
|
13
13
|
import { Parser } from "htmlparser2";
|
|
@@ -13665,6 +13665,16 @@ var GoogleApiError = class extends SeoError {
|
|
|
13665
13665
|
this.status = status;
|
|
13666
13666
|
}
|
|
13667
13667
|
};
|
|
13668
|
+
function bearerFetch(token) {
|
|
13669
|
+
return async (url, init) => fetch(url, {
|
|
13670
|
+
...init,
|
|
13671
|
+
headers: {
|
|
13672
|
+
...init?.headers,
|
|
13673
|
+
authorization: `Bearer ${await token()}`,
|
|
13674
|
+
"content-type": "application/json"
|
|
13675
|
+
}
|
|
13676
|
+
});
|
|
13677
|
+
}
|
|
13668
13678
|
async function failure(response, label, subject, options) {
|
|
13669
13679
|
const detail = (await response.text().catch(() => "")).slice(0, 300);
|
|
13670
13680
|
const status = response.status;
|
|
@@ -13728,6 +13738,18 @@ async function listSearchConsoleSites(gfetch, options) {
|
|
|
13728
13738
|
label: "Search Console"
|
|
13729
13739
|
})).siteEntry ?? [];
|
|
13730
13740
|
}
|
|
13741
|
+
async function inspectSearchConsoleUrl(gfetch, site, url, options) {
|
|
13742
|
+
return googleJson(gfetch, "https://searchconsole.googleapis.com/v1/urlInspection/index:inspect", {
|
|
13743
|
+
...options,
|
|
13744
|
+
label: "URL Inspection",
|
|
13745
|
+
subject: url,
|
|
13746
|
+
body: {
|
|
13747
|
+
siteUrl: site,
|
|
13748
|
+
inspectionUrl: url,
|
|
13749
|
+
...options.languageCode ? { languageCode: options.languageCode } : {}
|
|
13750
|
+
}
|
|
13751
|
+
});
|
|
13752
|
+
}
|
|
13731
13753
|
async function listAnalyticsAccounts(gfetch, options) {
|
|
13732
13754
|
return collectGa4AccountSummaries(async (pageToken) => {
|
|
13733
13755
|
const url = new URL("https://analyticsadmin.googleapis.com/v1beta/accountSummaries");
|
|
@@ -13750,323 +13772,1241 @@ async function runAnalyticsReport(gfetch, propertyId, body, options) {
|
|
|
13750
13772
|
});
|
|
13751
13773
|
}
|
|
13752
13774
|
//#endregion
|
|
13753
|
-
//#region packages/core/dist/
|
|
13754
|
-
|
|
13755
|
-
|
|
13756
|
-
|
|
13757
|
-
return
|
|
13775
|
+
//#region packages/core/dist/bing/api.js
|
|
13776
|
+
const BASE = "https://ssl.bing.com/webmaster/api.svc/json";
|
|
13777
|
+
const MAX_BYTES$2 = 8e6;
|
|
13778
|
+
function bingCall(auth, options) {
|
|
13779
|
+
return async (method, params = {}) => {
|
|
13780
|
+
const url = new URL(`${BASE}/${method}`);
|
|
13781
|
+
for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);
|
|
13782
|
+
const headers = { accept: "application/json" };
|
|
13783
|
+
if ("apiKey" in auth) url.searchParams.set("apikey", auth.apiKey);
|
|
13784
|
+
else headers.authorization = `Bearer ${await auth.accessToken()}`;
|
|
13785
|
+
let response;
|
|
13786
|
+
try {
|
|
13787
|
+
response = await fetch(url, {
|
|
13788
|
+
headers,
|
|
13789
|
+
signal: AbortSignal.timeout(3e4)
|
|
13790
|
+
});
|
|
13791
|
+
} catch (error) {
|
|
13792
|
+
throw new SeoError("OPTIONAL_PROVIDER_UNAVAILABLE", `Bing Webmaster did not respond: ${error instanceof Error ? error.message : String(error)}`);
|
|
13793
|
+
}
|
|
13794
|
+
if (!response.ok) {
|
|
13795
|
+
const detail = (await response.text().catch(() => "")).slice(0, 200);
|
|
13796
|
+
if (response.status === 401 || /invalidapikey/i.test(detail)) throw new SeoError("AUTH_REQUIRED", `Bing Webmaster rejected the sign-in. ${options.signIn}`);
|
|
13797
|
+
if (response.status === 403 || /notauthorized/i.test(detail)) throw new SeoError("ACCESS_DENIED", "Bing Webmaster denied access to this site. It must be verified in the connected Bing account.");
|
|
13798
|
+
if (/ThrottleIP/i.test(detail)) throw new SeoError("RATE_LIMITED", "Bing Webmaster is blocking requests from this network (ThrottleIP). Run the same report with the CLI on your own machine, where Bing answers.");
|
|
13799
|
+
if (response.status === 429 || /throttle/i.test(detail)) throw new SeoError("RATE_LIMITED", "Bing Webmaster is limiting requests. Try again in a few minutes.");
|
|
13800
|
+
throw new SeoError("OPTIONAL_PROVIDER_UNAVAILABLE", `Bing Webmaster answered ${response.status}. ${detail}`.trim());
|
|
13801
|
+
}
|
|
13802
|
+
return (await readJson(response, "Bing Webmaster", { maxBytes: MAX_BYTES$2 })).d;
|
|
13803
|
+
};
|
|
13758
13804
|
}
|
|
13759
|
-
function
|
|
13760
|
-
if (
|
|
13761
|
-
|
|
13762
|
-
|
|
13805
|
+
function day(value) {
|
|
13806
|
+
if (typeof value !== "string") return null;
|
|
13807
|
+
const legacy = /^\/Date\((-?\d+)(?:[+-]\d{4})?\)\/$/.exec(value);
|
|
13808
|
+
const date = legacy ? new Date(Number(legacy[1])) : new Date(value);
|
|
13809
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10);
|
|
13763
13810
|
}
|
|
13764
|
-
|
|
13765
|
-
|
|
13766
|
-
if (
|
|
13767
|
-
return
|
|
13811
|
+
const count$1 = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
|
13812
|
+
function list(data, label) {
|
|
13813
|
+
if (!Array.isArray(data)) throw new SeoError("OPTIONAL_PROVIDER_UNAVAILABLE", `Bing Webmaster returned an invalid ${label}.`);
|
|
13814
|
+
return data.filter((item) => Boolean(item) && typeof item === "object");
|
|
13768
13815
|
}
|
|
13769
|
-
function
|
|
13770
|
-
return {
|
|
13771
|
-
|
|
13772
|
-
|
|
13773
|
-
|
|
13774
|
-
};
|
|
13816
|
+
async function listBingSites(call) {
|
|
13817
|
+
return list(await call("GetUserSites"), "site list").filter((row) => typeof row.Url === "string").map((row) => ({
|
|
13818
|
+
url: row.Url,
|
|
13819
|
+
verified: row.IsVerified === true
|
|
13820
|
+
})).sort((a, b) => a.url.localeCompare(b.url, "en"));
|
|
13775
13821
|
}
|
|
13776
|
-
function
|
|
13777
|
-
const
|
|
13778
|
-
|
|
13779
|
-
const
|
|
13822
|
+
async function bingTraffic(call, site) {
|
|
13823
|
+
const rows = [];
|
|
13824
|
+
let invalidRows = 0;
|
|
13825
|
+
for (const row of list(await call("GetRankAndTrafficStats", { siteUrl: site }), "traffic list")) {
|
|
13826
|
+
const date = day(row.Date);
|
|
13827
|
+
const clicks = count$1(row.Clicks);
|
|
13828
|
+
const impressions = count$1(row.Impressions);
|
|
13829
|
+
if (!date || clicks === null || impressions === null) {
|
|
13830
|
+
invalidRows += 1;
|
|
13831
|
+
continue;
|
|
13832
|
+
}
|
|
13833
|
+
rows.push({
|
|
13834
|
+
date,
|
|
13835
|
+
clicks,
|
|
13836
|
+
impressions
|
|
13837
|
+
});
|
|
13838
|
+
}
|
|
13839
|
+
rows.sort((a, b) => a.date.localeCompare(b.date, "en"));
|
|
13780
13840
|
return {
|
|
13781
|
-
|
|
13782
|
-
|
|
13783
|
-
logDir: options.env.SEO_LOG_DIR ?? defaultLogDir(options),
|
|
13784
|
-
providerPackagesDir: join(configDir, "providers"),
|
|
13785
|
-
installedProviderPackagesFile: join(configDir, "provider-packages.json"),
|
|
13786
|
-
configFile: join(configDir, "config.json"),
|
|
13787
|
-
tokensFile: join(configDir, "tokens.json"),
|
|
13788
|
-
oauthClientFile: join(configDir, "oauth-client.json"),
|
|
13789
|
-
providerSecretsFile: join(configDir, "provider-secrets.json"),
|
|
13790
|
-
telemetryStateFile: join(configDir, "telemetry.json"),
|
|
13791
|
-
cacheDbFile: join(cacheDir, "cache.db")
|
|
13841
|
+
rows,
|
|
13842
|
+
invalidRows
|
|
13792
13843
|
};
|
|
13793
13844
|
}
|
|
13794
|
-
function
|
|
13795
|
-
const
|
|
13796
|
-
|
|
13797
|
-
|
|
13798
|
-
|
|
13799
|
-
|
|
13800
|
-
|
|
13801
|
-
|
|
13802
|
-
|
|
13803
|
-
|
|
13804
|
-
|
|
13805
|
-
|
|
13806
|
-
|
|
13807
|
-
|
|
13845
|
+
async function keyed(call, method, params) {
|
|
13846
|
+
const rows = [];
|
|
13847
|
+
let invalidRows = 0;
|
|
13848
|
+
for (const row of list(await call(method, params), "row list")) {
|
|
13849
|
+
const date = day(row.Date);
|
|
13850
|
+
const key = typeof row.Query === "string" ? row.Query.trim() : "";
|
|
13851
|
+
const clicks = count$1(row.Clicks);
|
|
13852
|
+
const impressions = count$1(row.Impressions);
|
|
13853
|
+
const position = count$1(row.AvgImpressionPosition);
|
|
13854
|
+
if (!date || !key || clicks === null || impressions === null) {
|
|
13855
|
+
invalidRows += 1;
|
|
13856
|
+
continue;
|
|
13857
|
+
}
|
|
13858
|
+
rows.push({
|
|
13859
|
+
date,
|
|
13860
|
+
key,
|
|
13861
|
+
clicks,
|
|
13862
|
+
impressions,
|
|
13863
|
+
position
|
|
13808
13864
|
});
|
|
13809
|
-
renameSync(legacyDir, paths.configDir);
|
|
13810
|
-
} catch (error) {
|
|
13811
|
-
const code = error.code;
|
|
13812
|
-
if (code !== "EEXIST" && code !== "ENOENT") throw error;
|
|
13813
13865
|
}
|
|
13866
|
+
rows.sort((a, b) => a.date.localeCompare(b.date, "en") || a.key.localeCompare(b.key, "en"));
|
|
13867
|
+
return {
|
|
13868
|
+
rows,
|
|
13869
|
+
invalidRows
|
|
13870
|
+
};
|
|
13814
13871
|
}
|
|
13815
|
-
|
|
13816
|
-
|
|
13817
|
-
|
|
13818
|
-
|
|
13819
|
-
|
|
13820
|
-
|
|
13821
|
-
|
|
13822
|
-
|
|
13823
|
-
|
|
13824
|
-
|
|
13825
|
-
|
|
13826
|
-
|
|
13827
|
-
|
|
13828
|
-
recursive: true,
|
|
13829
|
-
mode: 448
|
|
13830
|
-
});
|
|
13831
|
-
mkdirSync(paths.logDir, {
|
|
13832
|
-
recursive: true,
|
|
13833
|
-
mode: 448
|
|
13834
|
-
});
|
|
13835
|
-
mkdirSync(paths.providerPackagesDir, {
|
|
13836
|
-
recursive: true,
|
|
13837
|
-
mode: 448
|
|
13838
|
-
});
|
|
13839
|
-
return paths;
|
|
13872
|
+
const bingQueries = (call, site) => keyed(call, "GetQueryStats", { siteUrl: site });
|
|
13873
|
+
const bingPages = (call, site) => keyed(call, "GetPageStats", { siteUrl: site });
|
|
13874
|
+
function bingSiteFor(sites, target) {
|
|
13875
|
+
const host = (value) => {
|
|
13876
|
+
try {
|
|
13877
|
+
return new URL(value.includes("://") ? value : `https://${value.replace(/^sc-domain:/, "")}`).hostname.replace(/^www\./, "");
|
|
13878
|
+
} catch {
|
|
13879
|
+
return null;
|
|
13880
|
+
}
|
|
13881
|
+
};
|
|
13882
|
+
const wanted = host(target);
|
|
13883
|
+
if (!wanted) return null;
|
|
13884
|
+
return sites.filter((site) => host(site.url) === wanted).sort((a, b) => Number(b.verified) - Number(a.verified) || Number(b.url.startsWith("https://")) - Number(a.url.startsWith("https://")) || a.url.localeCompare(b.url, "en"))[0] ?? null;
|
|
13840
13885
|
}
|
|
13841
13886
|
//#endregion
|
|
13842
|
-
//#region packages/core/dist/
|
|
13843
|
-
const
|
|
13844
|
-
|
|
13845
|
-
|
|
13846
|
-
|
|
13847
|
-
|
|
13848
|
-
|
|
13849
|
-
|
|
13850
|
-
|
|
13851
|
-
|
|
13852
|
-
client_source: z.enum(["shared", "byo"])
|
|
13853
|
-
});
|
|
13854
|
-
const tokenStoreSchema = z.object({
|
|
13855
|
-
version: z.literal(2),
|
|
13856
|
-
active_account: z.string().email(),
|
|
13857
|
-
accounts: z.array(tokenSchema).min(1).max(50)
|
|
13858
|
-
}).superRefine((store, context) => {
|
|
13859
|
-
const emails = store.accounts.map((tokens) => tokens.account_email.toLowerCase());
|
|
13860
|
-
if (new Set(emails).size !== emails.length) context.addIssue({
|
|
13861
|
-
code: "custom",
|
|
13862
|
-
path: ["accounts"],
|
|
13863
|
-
message: "Google account emails must be unique."
|
|
13864
|
-
});
|
|
13865
|
-
if (!emails.includes(store.active_account.toLowerCase())) context.addIssue({
|
|
13866
|
-
code: "custom",
|
|
13867
|
-
path: ["active_account"],
|
|
13868
|
-
message: "The active Google account must exist in accounts."
|
|
13869
|
-
});
|
|
13870
|
-
});
|
|
13871
|
-
//#endregion
|
|
13872
|
-
//#region packages/core/dist/providers/contracts.js
|
|
13873
|
-
const providerIdSchema = z.string().min(1).max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u);
|
|
13874
|
-
z.enum([
|
|
13875
|
-
"keyword-metrics",
|
|
13876
|
-
"keyword-discovery",
|
|
13877
|
-
"serp-snapshot",
|
|
13878
|
-
"domain-overview",
|
|
13879
|
-
"ranked-keywords",
|
|
13880
|
-
"relevant-pages",
|
|
13881
|
-
"serp-competitors",
|
|
13882
|
-
"link-summary",
|
|
13883
|
-
"referring-domains",
|
|
13884
|
-
"backlinks",
|
|
13885
|
-
"domain-rating",
|
|
13886
|
-
"local-search",
|
|
13887
|
-
"ai-mentions",
|
|
13888
|
-
"ai-prompt-observation"
|
|
13889
|
-
]);
|
|
13890
|
-
//#endregion
|
|
13891
|
-
//#region packages/core/dist/types/config.js
|
|
13892
|
-
const providerPreferenceSchema = z.enum(["cheap", "authoritative"]);
|
|
13893
|
-
z.enum(["google", "clicky"]);
|
|
13894
|
-
const analyticsExtensionIdSchema = z.string().min(1).max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u);
|
|
13895
|
-
const analyticsExtensionAccountSchema = z.record(z.string().min(1).max(64).regex(/^[A-Za-z][A-Za-z0-9-]*$/u).refine((value) => ![
|
|
13896
|
-
"constructor",
|
|
13897
|
-
"prototype",
|
|
13898
|
-
"__proto__"
|
|
13899
|
-
].includes(value)), z.string().trim().min(1).max(1e3)).refine((value) => Object.keys(value).length <= 16, { message: "An analytics provider account can have at most 16 fields." });
|
|
13900
|
-
const providerConnectionAccountsSchema = z.record(providerIdSchema, analyticsExtensionAccountSchema).refine((value) => Object.keys(value).length <= 20, { message: "There can be at most 20 provider connection records." });
|
|
13901
|
-
const analyticsConnectionSchema = z.discriminatedUnion("provider", [
|
|
13902
|
-
z.object({
|
|
13903
|
-
provider: z.literal("google"),
|
|
13904
|
-
propertyId: z.string(),
|
|
13905
|
-
accountEmail: z.string().email().optional()
|
|
13906
|
-
}),
|
|
13907
|
-
z.object({
|
|
13908
|
-
provider: z.literal("clicky"),
|
|
13909
|
-
siteId: z.string().regex(/^\d{1,30}$/u)
|
|
13910
|
-
}),
|
|
13911
|
-
z.object({
|
|
13912
|
-
provider: z.literal("extension"),
|
|
13913
|
-
providerId: analyticsExtensionIdSchema,
|
|
13914
|
-
account: analyticsExtensionAccountSchema
|
|
13915
|
-
})
|
|
13916
|
-
]);
|
|
13917
|
-
const configSchema = z.looseObject({
|
|
13918
|
-
providers: z.object({
|
|
13919
|
-
dataForSeoLogin: z.string().optional(),
|
|
13920
|
-
dataForSeoPassword: z.string().optional(),
|
|
13921
|
-
prefer: providerPreferenceSchema.default("cheap"),
|
|
13922
|
-
connections: providerConnectionAccountsSchema.optional()
|
|
13923
|
-
}).default({ prefer: "cheap" }),
|
|
13924
|
-
security: z.object({ useKeychain: z.boolean().default(true) }).default({ useKeychain: true }),
|
|
13925
|
-
auth: z.object({
|
|
13926
|
-
sharedClientId: z.string().optional(),
|
|
13927
|
-
sharedClientSecret: z.string().optional()
|
|
13928
|
-
}).default({})
|
|
13929
|
-
});
|
|
13930
|
-
//#endregion
|
|
13931
|
-
//#region packages/core/dist/storage/files.js
|
|
13932
|
-
function ensureParentDir(path, mode = 448) {
|
|
13933
|
-
mkdirSync(dirname(path), {
|
|
13934
|
-
recursive: true,
|
|
13935
|
-
mode
|
|
13936
|
-
});
|
|
13937
|
-
}
|
|
13938
|
-
function readJsonFile(path) {
|
|
13939
|
-
try {
|
|
13940
|
-
return JSON.parse(readFileSync(path, "utf8"));
|
|
13941
|
-
} catch {
|
|
13942
|
-
return;
|
|
13943
|
-
}
|
|
13944
|
-
}
|
|
13945
|
-
function writeJsonAtomic(path, data, mode) {
|
|
13946
|
-
ensureParentDir(path);
|
|
13947
|
-
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
13948
|
-
writeFileSync(tmp, JSON.stringify(data, null, 2), {
|
|
13949
|
-
encoding: "utf8",
|
|
13950
|
-
mode
|
|
13951
|
-
});
|
|
13952
|
-
chmodSync(tmp, mode);
|
|
13953
|
-
renameSync(tmp, path);
|
|
13954
|
-
chmodSync(path, mode);
|
|
13955
|
-
}
|
|
13956
|
-
function safeRemove(path) {
|
|
13957
|
-
rmSync(path, {
|
|
13958
|
-
recursive: true,
|
|
13959
|
-
force: true
|
|
13960
|
-
});
|
|
13961
|
-
}
|
|
13962
|
-
let loadedKeyring;
|
|
13963
|
-
async function resolveKeyring() {
|
|
13964
|
-
if (!loadedKeyring) loadedKeyring = import("@napi-rs/keyring/keytar.js").then((module) => module);
|
|
13965
|
-
return loadedKeyring;
|
|
13887
|
+
//#region packages/core/dist/gsc/dates.js
|
|
13888
|
+
const GSC_TIME_ZONE = "America/Los_Angeles";
|
|
13889
|
+
function dateInTimeZone(now, timeZone) {
|
|
13890
|
+
const parts = Object.fromEntries(new Intl.DateTimeFormat("en-US", {
|
|
13891
|
+
timeZone,
|
|
13892
|
+
year: "numeric",
|
|
13893
|
+
month: "2-digit",
|
|
13894
|
+
day: "2-digit"
|
|
13895
|
+
}).formatToParts(now).map((part) => [part.type, part.value]));
|
|
13896
|
+
return new Date(Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day)));
|
|
13966
13897
|
}
|
|
13967
|
-
|
|
13968
|
-
|
|
13898
|
+
function shiftIsoDate(date, days) {
|
|
13899
|
+
const value = /* @__PURE__ */ new Date(`${date}T00:00:00.000Z`);
|
|
13900
|
+
value.setUTCDate(value.getUTCDate() + days);
|
|
13901
|
+
return value.toISOString().slice(0, 10);
|
|
13969
13902
|
}
|
|
13970
|
-
|
|
13971
|
-
|
|
13903
|
+
function latestFinalGscDate(now = /* @__PURE__ */ new Date()) {
|
|
13904
|
+
const pacificToday = dateInTimeZone(now, GSC_TIME_ZONE);
|
|
13905
|
+
pacificToday.setUTCDate(pacificToday.getUTCDate() - 4);
|
|
13906
|
+
return pacificToday.toISOString().slice(0, 10);
|
|
13972
13907
|
}
|
|
13973
|
-
|
|
13974
|
-
|
|
13908
|
+
function finalGscDateRange(days = 28, now = /* @__PURE__ */ new Date()) {
|
|
13909
|
+
const endDate = latestFinalGscDate(now);
|
|
13910
|
+
return {
|
|
13911
|
+
startDate: shiftIsoDate(endDate, -(days - 1)),
|
|
13912
|
+
endDate
|
|
13913
|
+
};
|
|
13975
13914
|
}
|
|
13976
13915
|
//#endregion
|
|
13977
|
-
//#region packages/core/dist/
|
|
13978
|
-
|
|
13979
|
-
|
|
13980
|
-
|
|
13981
|
-
|
|
13982
|
-
chmodSync(path, PRIVATE_FILE_MODE$1);
|
|
13983
|
-
} catch (error) {
|
|
13984
|
-
if (error.code !== "ENOENT") throw error;
|
|
13985
|
-
}
|
|
13916
|
+
//#region packages/core/dist/analyze/site-diagnostics/quick-wins-report-input.js
|
|
13917
|
+
function integerOption$1(input) {
|
|
13918
|
+
if (input.value === void 0) return input.fallback;
|
|
13919
|
+
if (!Number.isInteger(input.value) || input.value < input.minimum || input.value > input.maximum) throw new SeoError("INVALID_INPUT", `${input.label} must be a whole number between ${input.minimum} and ${input.maximum}.`);
|
|
13920
|
+
return input.value;
|
|
13986
13921
|
}
|
|
13987
|
-
function
|
|
13988
|
-
|
|
13989
|
-
const configFile = getSeoCliPaths().configFile;
|
|
13990
|
-
tightenConfigPermissions(configFile);
|
|
13991
|
-
const raw = readJsonFile(configFile);
|
|
13992
|
-
return configSchema.parse(raw ?? {});
|
|
13922
|
+
function defaultDateRange$1(days, now) {
|
|
13923
|
+
return finalGscDateRange(days, now);
|
|
13993
13924
|
}
|
|
13994
|
-
function
|
|
13995
|
-
|
|
13996
|
-
|
|
13925
|
+
function explicitDateRange(input, maximumDays) {
|
|
13926
|
+
if (!input.startDate && !input.endDate) return void 0;
|
|
13927
|
+
if (!input.startDate || !input.endDate) throw new SeoError("INVALID_INPUT", "Start date and end date must be provided together.");
|
|
13928
|
+
const pattern = /^\d{4}-\d{2}-\d{2}$/;
|
|
13929
|
+
const start = /* @__PURE__ */ new Date(`${input.startDate}T00:00:00.000Z`);
|
|
13930
|
+
const end = /* @__PURE__ */ new Date(`${input.endDate}T00:00:00.000Z`);
|
|
13931
|
+
if (!pattern.test(input.startDate) || !pattern.test(input.endDate) || Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || start.toISOString().slice(0, 10) !== input.startDate || end.toISOString().slice(0, 10) !== input.endDate || start > end) throw new SeoError("INVALID_INPUT", "Start date and end date must be valid YYYY-MM-DD values in ascending order.");
|
|
13932
|
+
const days = Math.floor((end.getTime() - start.getTime()) / 864e5) + 1;
|
|
13933
|
+
if (days > maximumDays) throw new SeoError("INVALID_INPUT", `Date range must not exceed ${maximumDays} days.`);
|
|
13934
|
+
return {
|
|
13935
|
+
range: {
|
|
13936
|
+
startDate: input.startDate,
|
|
13937
|
+
endDate: input.endDate
|
|
13938
|
+
},
|
|
13939
|
+
days
|
|
13940
|
+
};
|
|
13997
13941
|
}
|
|
13998
|
-
|
|
13999
|
-
|
|
13942
|
+
//#endregion
|
|
13943
|
+
//#region packages/core/dist/analyze/site-diagnostics/decay-report-input.js
|
|
13944
|
+
function decayNumberOption(input) {
|
|
13945
|
+
const value = input.value ?? input.fallback;
|
|
13946
|
+
if (!Number.isFinite(value) || value < input.minimum || value > input.maximum) throw new SeoError("INVALID_INPUT", `${input.label} must be between ${input.minimum} and ${input.maximum}.`);
|
|
13947
|
+
return value;
|
|
14000
13948
|
}
|
|
14001
|
-
function
|
|
14002
|
-
return
|
|
13949
|
+
function rangeDays$1(range) {
|
|
13950
|
+
return Math.floor(((/* @__PURE__ */ new Date(`${range.endDate}T00:00:00.000Z`)).getTime() - (/* @__PURE__ */ new Date(`${range.startDate}T00:00:00.000Z`)).getTime()) / 864e5) + 1;
|
|
14003
13951
|
}
|
|
14004
|
-
function
|
|
14005
|
-
return
|
|
14006
|
-
...tokens,
|
|
14007
|
-
access_token: void 0,
|
|
14008
|
-
refresh_token: void 0
|
|
14009
|
-
};
|
|
13952
|
+
function dateString(date) {
|
|
13953
|
+
return date.toISOString().slice(0, 10);
|
|
14010
13954
|
}
|
|
14011
|
-
function
|
|
14012
|
-
|
|
13955
|
+
function subtractDays$1(value, days) {
|
|
13956
|
+
const date = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
13957
|
+
date.setUTCDate(date.getUTCDate() - days);
|
|
13958
|
+
return dateString(date);
|
|
14013
13959
|
}
|
|
14014
|
-
function
|
|
14015
|
-
|
|
14016
|
-
const
|
|
14017
|
-
|
|
14018
|
-
const
|
|
14019
|
-
|
|
14020
|
-
return
|
|
14021
|
-
|
|
14022
|
-
active_account: legacy.data.account_email,
|
|
14023
|
-
accounts: [legacy.data]
|
|
14024
|
-
};
|
|
13960
|
+
function priorYearDate(value) {
|
|
13961
|
+
const source = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
13962
|
+
const year = source.getUTCFullYear() - 1;
|
|
13963
|
+
const month = source.getUTCMonth();
|
|
13964
|
+
const day = source.getUTCDate();
|
|
13965
|
+
const candidate = new Date(Date.UTC(year, month, day));
|
|
13966
|
+
if (candidate.getUTCMonth() !== month) return dateString(new Date(Date.UTC(year, month + 1, 0)));
|
|
13967
|
+
return dateString(candidate);
|
|
14025
13968
|
}
|
|
14026
|
-
function
|
|
14027
|
-
|
|
14028
|
-
|
|
13969
|
+
function subtractCalendarMonths(date, months) {
|
|
13970
|
+
const day = date.getUTCDate();
|
|
13971
|
+
const target = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() - months, 1));
|
|
13972
|
+
const lastDay = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate();
|
|
13973
|
+
target.setUTCDate(Math.min(day, lastDay));
|
|
13974
|
+
return dateString(target);
|
|
14029
13975
|
}
|
|
14030
|
-
function
|
|
14031
|
-
|
|
13976
|
+
function validateDecayRanges(input) {
|
|
13977
|
+
const latestFinalDate = defaultDateRange$1(1, input.now).endDate;
|
|
13978
|
+
if (input.current.endDate > latestFinalDate) throw new SeoError("INVALID_INPUT", `Decay end date must be ${latestFinalDate} or earlier so both GSC windows use final data.`);
|
|
13979
|
+
const pacificToday = /* @__PURE__ */ new Date(`${latestFinalDate}T00:00:00.000Z`);
|
|
13980
|
+
pacificToday.setUTCDate(pacificToday.getUTCDate() + 4);
|
|
13981
|
+
const retentionStart = subtractCalendarMonths(pacificToday, 16);
|
|
13982
|
+
if (input.previous.startDate < retentionStart) throw new SeoError("INVALID_INPUT", `Comparison range starts before Search Console's rolling 16-month API history (${retentionStart}). Choose a shorter range or use previously stored data.`);
|
|
14032
13983
|
}
|
|
14033
|
-
|
|
14034
|
-
const
|
|
14035
|
-
|
|
13984
|
+
function decayComparisonRange(current, comparison = "previous-period") {
|
|
13985
|
+
const days = rangeDays$1(current);
|
|
13986
|
+
if (comparison === "year-over-year") {
|
|
13987
|
+
const endDate = priorYearDate(current.endDate);
|
|
13988
|
+
return {
|
|
13989
|
+
startDate: subtractDays$1(endDate, days - 1),
|
|
13990
|
+
endDate
|
|
13991
|
+
};
|
|
13992
|
+
}
|
|
13993
|
+
const endDate = subtractDays$1(current.startDate, 1);
|
|
14036
13994
|
return {
|
|
14037
|
-
|
|
14038
|
-
|
|
13995
|
+
startDate: subtractDays$1(endDate, days - 1),
|
|
13996
|
+
endDate
|
|
14039
13997
|
};
|
|
14040
13998
|
}
|
|
14041
|
-
|
|
14042
|
-
|
|
14043
|
-
|
|
14044
|
-
|
|
14045
|
-
if (
|
|
14046
|
-
|
|
14047
|
-
|
|
14048
|
-
|
|
14049
|
-
|
|
14050
|
-
|
|
13999
|
+
//#endregion
|
|
14000
|
+
//#region packages/core/dist/updates/search-updates.js
|
|
14001
|
+
function classifyUpdate(name, product) {
|
|
14002
|
+
const lower = `${name} ${product}`.toLowerCase();
|
|
14003
|
+
if (lower.includes("core")) return "core";
|
|
14004
|
+
if (lower.includes("spam")) return "spam";
|
|
14005
|
+
if (lower.includes("discover")) return "discover";
|
|
14006
|
+
if (lower.includes("ranking")) return "ranking";
|
|
14007
|
+
if (lower.includes("index")) return "indexing";
|
|
14008
|
+
if (lower.includes("serving")) return "serving";
|
|
14009
|
+
return "other";
|
|
14051
14010
|
}
|
|
14052
|
-
|
|
14053
|
-
|
|
14054
|
-
|
|
14055
|
-
const
|
|
14011
|
+
function searchUpdatesFromIncidents(incidents, input = {}) {
|
|
14012
|
+
const product = input.product?.toLowerCase();
|
|
14013
|
+
return incidents.filter((incident) => !product || incident.service_name.toLowerCase() === product).map((incident) => {
|
|
14014
|
+
const status = incident.end ? "complete" : incident.status_impact === "SERVICE_INFORMATION" ? "rolling-out" : "incident";
|
|
14056
14015
|
return {
|
|
14057
|
-
|
|
14058
|
-
|
|
14059
|
-
|
|
14016
|
+
id: incident.id,
|
|
14017
|
+
name: incident.external_desc,
|
|
14018
|
+
type: classifyUpdate(incident.external_desc, incident.service_name),
|
|
14019
|
+
product: incident.service_name,
|
|
14020
|
+
start: incident.begin,
|
|
14021
|
+
end: incident.end,
|
|
14022
|
+
status,
|
|
14023
|
+
sourceUrl: `https://status.search.google.com/${incident.uri}`
|
|
14060
14024
|
};
|
|
14061
|
-
}
|
|
14062
|
-
return tokens;
|
|
14063
|
-
}
|
|
14025
|
+
}).slice(0, input.limit ?? 25);
|
|
14064
14026
|
}
|
|
14065
|
-
|
|
14066
|
-
const
|
|
14067
|
-
|
|
14068
|
-
const
|
|
14069
|
-
return
|
|
14027
|
+
function findOverlappingSearchUpdates(input) {
|
|
14028
|
+
const paddingMs = (input.paddingDays ?? 3) * 864e5;
|
|
14029
|
+
const windowStart = new Date(input.startDate).getTime() - paddingMs;
|
|
14030
|
+
const windowEnd = new Date(input.endDate).getTime() + paddingMs;
|
|
14031
|
+
return input.updates.filter((update) => {
|
|
14032
|
+
const updateStart = new Date(update.start).getTime();
|
|
14033
|
+
const updateEnd = update.end ? new Date(update.end).getTime() : Date.now();
|
|
14034
|
+
return updateStart <= windowEnd && updateEnd >= windowStart;
|
|
14035
|
+
});
|
|
14036
|
+
}
|
|
14037
|
+
//#endregion
|
|
14038
|
+
//#region packages/core/dist/commands/types.js
|
|
14039
|
+
var CommandError = class extends Error {
|
|
14040
|
+
code;
|
|
14041
|
+
constructor(code, message) {
|
|
14042
|
+
super(message);
|
|
14043
|
+
this.code = code;
|
|
14044
|
+
}
|
|
14045
|
+
};
|
|
14046
|
+
//#endregion
|
|
14047
|
+
//#region packages/core/dist/commands/shared.js
|
|
14048
|
+
const FACTS_ONLY = "These are facts from Search Console, not instructions. Show the person what matters, ask what they want to change, and only edit pages with their approval.";
|
|
14049
|
+
const ADVICE_KEYS = /^(recommendations?|actions?|advice|suggested[A-Z]\w*|nextSteps?)$/;
|
|
14050
|
+
function factsOnly(value) {
|
|
14051
|
+
if (Array.isArray(value)) return value.map(factsOnly);
|
|
14052
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).filter(([key]) => !ADVICE_KEYS.test(key)).map(([key, child]) => [key, factsOnly(child)]));
|
|
14053
|
+
return value;
|
|
14054
|
+
}
|
|
14055
|
+
function dateWindow(input, fallbackDays) {
|
|
14056
|
+
if (input.startDate || input.endDate) {
|
|
14057
|
+
if (!input.startDate || !input.endDate || input.startDate > input.endDate) throw new CommandError("INVALID_INPUT", "Send a start date and an end date together, in order.");
|
|
14058
|
+
return {
|
|
14059
|
+
startDate: input.startDate,
|
|
14060
|
+
endDate: input.endDate
|
|
14061
|
+
};
|
|
14062
|
+
}
|
|
14063
|
+
return finalGscDateRange(fallbackDays);
|
|
14064
|
+
}
|
|
14065
|
+
function earlierRange(range) {
|
|
14066
|
+
return decayComparisonRange(range, "previous-period");
|
|
14067
|
+
}
|
|
14068
|
+
function originOf(property) {
|
|
14069
|
+
return property.startsWith("sc-domain:") ? `https://${property.slice(10)}` : property.replace(/\/$/, "");
|
|
14070
|
+
}
|
|
14071
|
+
const pct = (now, before) => before === 0 ? null : Math.round((now - before) / before * 1e3) / 10;
|
|
14072
|
+
function mergeRows(rows) {
|
|
14073
|
+
const merged = /* @__PURE__ */ new Map();
|
|
14074
|
+
for (const row of rows) {
|
|
14075
|
+
const key = row.keys.join("\0");
|
|
14076
|
+
const found = merged.get(key);
|
|
14077
|
+
if (!found) {
|
|
14078
|
+
merged.set(key, { ...row });
|
|
14079
|
+
continue;
|
|
14080
|
+
}
|
|
14081
|
+
const impressions = found.impressions + row.impressions;
|
|
14082
|
+
found.position = impressions > 0 ? (found.position * found.impressions + row.position * row.impressions) / impressions : found.position;
|
|
14083
|
+
found.clicks += row.clicks;
|
|
14084
|
+
found.impressions = impressions;
|
|
14085
|
+
found.ctr = impressions > 0 ? found.clicks / impressions : 0;
|
|
14086
|
+
}
|
|
14087
|
+
return [...merged.values()];
|
|
14088
|
+
}
|
|
14089
|
+
function sharedReader(read, redirects = /* @__PURE__ */ new Map()) {
|
|
14090
|
+
const memo = /* @__PURE__ */ new Map();
|
|
14091
|
+
const merged = async (site, body, opts) => {
|
|
14092
|
+
const result = await read(site, body, opts);
|
|
14093
|
+
const pageAt = (body.dimensions ?? []).indexOf("page");
|
|
14094
|
+
if (pageAt < 0 || redirects.size === 0) return result;
|
|
14095
|
+
return {
|
|
14096
|
+
...result,
|
|
14097
|
+
rows: mergeRows(result.rows.map((row) => {
|
|
14098
|
+
const target = redirects.get(row.keys[pageAt] ?? "");
|
|
14099
|
+
if (!target) return row;
|
|
14100
|
+
const keys = [...row.keys];
|
|
14101
|
+
keys[pageAt] = target;
|
|
14102
|
+
return {
|
|
14103
|
+
...row,
|
|
14104
|
+
keys
|
|
14105
|
+
};
|
|
14106
|
+
}))
|
|
14107
|
+
};
|
|
14108
|
+
};
|
|
14109
|
+
return (site, body, opts) => {
|
|
14110
|
+
const key = JSON.stringify([site, body]);
|
|
14111
|
+
let found = memo.get(key);
|
|
14112
|
+
if (!found) {
|
|
14113
|
+
found = merged(site, body, opts);
|
|
14114
|
+
memo.set(key, found);
|
|
14115
|
+
}
|
|
14116
|
+
return found;
|
|
14117
|
+
};
|
|
14118
|
+
}
|
|
14119
|
+
async function googleUpdates(window) {
|
|
14120
|
+
if (!window) return [];
|
|
14121
|
+
const incidents = await fetch("https://status.search.google.com/incidents.json", { signal: AbortSignal.timeout(8e3) }).then((response) => response.ok ? response.json() : null).catch(() => null);
|
|
14122
|
+
if (!Array.isArray(incidents)) return "Google Search Status could not be read.";
|
|
14123
|
+
return findOverlappingSearchUpdates({
|
|
14124
|
+
updates: searchUpdatesFromIncidents(incidents, {
|
|
14125
|
+
product: "Ranking",
|
|
14126
|
+
limit: 50
|
|
14127
|
+
}),
|
|
14128
|
+
startDate: window.start,
|
|
14129
|
+
endDate: window.end,
|
|
14130
|
+
paddingDays: 3
|
|
14131
|
+
});
|
|
14132
|
+
}
|
|
14133
|
+
function checkedDates(checked) {
|
|
14134
|
+
const window = checked;
|
|
14135
|
+
if (!window?.start || !window.end) return "in this window";
|
|
14136
|
+
const dates = window.start === window.end ? window.start : `${window.start} to ${window.end}`;
|
|
14137
|
+
return `within ${window.paddingDays ?? 3} days of ${dates}`;
|
|
14138
|
+
}
|
|
14139
|
+
function listOfUpdates(value, checked) {
|
|
14140
|
+
if (typeof value === "string") return value;
|
|
14141
|
+
if (!Array.isArray(value)) return null;
|
|
14142
|
+
const where = checkedDates(checked);
|
|
14143
|
+
if (value.length === 0) return `Google updates ${where}: none.`;
|
|
14144
|
+
return `Google updates ${where} (timing only, not cause): ${value.map((update) => `${update.name} (${update.start.slice(0, 10)} to ${update.end?.slice(0, 10) ?? "ongoing"})`).join("; ")}.`;
|
|
14145
|
+
}
|
|
14146
|
+
function matchProperty(names, site) {
|
|
14147
|
+
if (names.includes(site)) return site;
|
|
14148
|
+
let host;
|
|
14149
|
+
let origin;
|
|
14150
|
+
try {
|
|
14151
|
+
const url = new URL(site.includes("://") ? site : `https://${site.replace(/^sc-domain:/, "")}`);
|
|
14152
|
+
host = url.hostname.replace(/^www\./, "");
|
|
14153
|
+
origin = url.origin;
|
|
14154
|
+
} catch {
|
|
14155
|
+
return null;
|
|
14156
|
+
}
|
|
14157
|
+
const domain = names.filter((name) => name.startsWith("sc-domain:")).map((name) => name.slice(10)).filter((name) => host === name || host.endsWith(`.${name}`)).sort((a, b) => b.length - a.length)[0];
|
|
14158
|
+
if (domain) return `sc-domain:${domain}`;
|
|
14159
|
+
return names.find((name) => name === `${origin}/` || name === `${origin.replace("://", "://www.")}/` || name === `${origin.replace("://www.", "://")}/`) ?? null;
|
|
14160
|
+
}
|
|
14161
|
+
//#endregion
|
|
14162
|
+
//#region packages/core/dist/commands/present.js
|
|
14163
|
+
const LIST_KEYS = [
|
|
14164
|
+
"items",
|
|
14165
|
+
"rows",
|
|
14166
|
+
"results",
|
|
14167
|
+
"pages",
|
|
14168
|
+
"urls",
|
|
14169
|
+
"queries",
|
|
14170
|
+
"keywords",
|
|
14171
|
+
"opportunities",
|
|
14172
|
+
"candidates",
|
|
14173
|
+
"clusters",
|
|
14174
|
+
"issues",
|
|
14175
|
+
"domains",
|
|
14176
|
+
"competitors",
|
|
14177
|
+
"links"
|
|
14178
|
+
];
|
|
14179
|
+
const SKIP_KEYS = /* @__PURE__ */ new Set([
|
|
14180
|
+
"caveats",
|
|
14181
|
+
"warnings",
|
|
14182
|
+
"recommendations",
|
|
14183
|
+
"findings",
|
|
14184
|
+
"sampleUrls",
|
|
14185
|
+
"sampleQueries",
|
|
14186
|
+
"omissions",
|
|
14187
|
+
"dimensions",
|
|
14188
|
+
"brand",
|
|
14189
|
+
"pageChecks"
|
|
14190
|
+
]);
|
|
14191
|
+
const ID_COLUMNS = [
|
|
14192
|
+
"metric",
|
|
14193
|
+
"direction",
|
|
14194
|
+
"changeDate",
|
|
14195
|
+
"query",
|
|
14196
|
+
"queryFlags",
|
|
14197
|
+
"keyword",
|
|
14198
|
+
"url",
|
|
14199
|
+
"page",
|
|
14200
|
+
"finalUrl",
|
|
14201
|
+
"title",
|
|
14202
|
+
"domain",
|
|
14203
|
+
"target",
|
|
14204
|
+
"check",
|
|
14205
|
+
"rule",
|
|
14206
|
+
"severity",
|
|
14207
|
+
"status",
|
|
14208
|
+
"urls",
|
|
14209
|
+
"topPage"
|
|
14210
|
+
];
|
|
14211
|
+
const METRIC_COLUMNS = [
|
|
14212
|
+
"percentChange",
|
|
14213
|
+
"baselineMean",
|
|
14214
|
+
"comparisonMean",
|
|
14215
|
+
"clicks",
|
|
14216
|
+
"impressions",
|
|
14217
|
+
"ctr",
|
|
14218
|
+
"position",
|
|
14219
|
+
"previousClicks",
|
|
14220
|
+
"clicksChange",
|
|
14221
|
+
"searchVolume",
|
|
14222
|
+
"volume",
|
|
14223
|
+
"cpc",
|
|
14224
|
+
"difficulty",
|
|
14225
|
+
"rank",
|
|
14226
|
+
"count",
|
|
14227
|
+
"affectedUrls",
|
|
14228
|
+
"score",
|
|
14229
|
+
"priority.score"
|
|
14230
|
+
];
|
|
14231
|
+
const SUMMARY_ROWS = 10;
|
|
14232
|
+
const CELL_MAX = 80;
|
|
14233
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
14234
|
+
const isPrimitive = (value) => value === null || [
|
|
14235
|
+
"string",
|
|
14236
|
+
"number",
|
|
14237
|
+
"boolean"
|
|
14238
|
+
].includes(typeof value);
|
|
14239
|
+
function mainList(report) {
|
|
14240
|
+
const found = [];
|
|
14241
|
+
const visit = (value, path, depth) => {
|
|
14242
|
+
if (!isObject(value) || depth > 2) return;
|
|
14243
|
+
for (const [key, child] of Object.entries(value)) {
|
|
14244
|
+
if (SKIP_KEYS.has(key)) continue;
|
|
14245
|
+
const childPath = path ? `${path}.${key}` : key;
|
|
14246
|
+
if (Array.isArray(child) && child.length > 0 && child.every(isObject)) found.push({
|
|
14247
|
+
path: childPath,
|
|
14248
|
+
rows: child,
|
|
14249
|
+
known: LIST_KEYS.includes(key)
|
|
14250
|
+
});
|
|
14251
|
+
else if (isObject(child)) visit(child, childPath, depth + 1);
|
|
14252
|
+
}
|
|
14253
|
+
};
|
|
14254
|
+
visit(report, "", 0);
|
|
14255
|
+
found.sort((a, b) => Number(b.known) - Number(a.known) || b.rows.length - a.rows.length || a.path.length - b.path.length);
|
|
14256
|
+
return found[0] ?? null;
|
|
14257
|
+
}
|
|
14258
|
+
function flatten(row) {
|
|
14259
|
+
const flat = {};
|
|
14260
|
+
for (const [key, value] of Object.entries(row)) if (isPrimitive(value)) flat[key] = value;
|
|
14261
|
+
else if (Array.isArray(value) && value.length && value.every(isPrimitive)) flat[key] = value.join(" ");
|
|
14262
|
+
else if (isObject(value)) {
|
|
14263
|
+
for (const [inner, innerValue] of Object.entries(value)) if (isPrimitive(innerValue)) flat[`${key}.${inner}`] = innerValue;
|
|
14264
|
+
}
|
|
14265
|
+
return flat;
|
|
14266
|
+
}
|
|
14267
|
+
function columnsFor(rows, max) {
|
|
14268
|
+
const seen = /* @__PURE__ */ new Map();
|
|
14269
|
+
for (const row of rows.slice(0, 50)) for (const [key, value] of Object.entries(row)) {
|
|
14270
|
+
const entry = seen.get(key) ?? {
|
|
14271
|
+
count: 0,
|
|
14272
|
+
length: 0
|
|
14273
|
+
};
|
|
14274
|
+
entry.count++;
|
|
14275
|
+
entry.length += String(value ?? "").length;
|
|
14276
|
+
seen.set(key, entry);
|
|
14277
|
+
}
|
|
14278
|
+
const usable = [...seen].filter(([key, { count, length }]) => {
|
|
14279
|
+
const average = length / count;
|
|
14280
|
+
return ID_COLUMNS.includes(key) || average <= 60;
|
|
14281
|
+
});
|
|
14282
|
+
const rank = (key) => {
|
|
14283
|
+
const id = ID_COLUMNS.indexOf(key);
|
|
14284
|
+
if (id >= 0) return id;
|
|
14285
|
+
const metric = METRIC_COLUMNS.indexOf(key);
|
|
14286
|
+
if (metric >= 0) return 100 + metric;
|
|
14287
|
+
return 1e3;
|
|
14288
|
+
};
|
|
14289
|
+
return usable.map(([key]) => key).filter((key) => !/(^|\.)(id|method|heuristic|estimatedClickLift|confidence|label)$|^site$/.test(key)).sort((a, b) => rank(a) - rank(b)).slice(0, max);
|
|
14290
|
+
}
|
|
14291
|
+
function cell(value, origin, max = CELL_MAX) {
|
|
14292
|
+
if (value === null || value === void 0) return "";
|
|
14293
|
+
let text = String(value);
|
|
14294
|
+
if (origin && text.startsWith(origin)) text = text.slice(origin.length) || "/";
|
|
14295
|
+
if (typeof value === "number" && !Number.isInteger(value)) {
|
|
14296
|
+
const places = Math.abs(value) < 1 ? 1e3 : 100;
|
|
14297
|
+
text = String(Math.round(value * places) / places);
|
|
14298
|
+
}
|
|
14299
|
+
text = text.replaceAll("|", "\\|").replaceAll("\n", " ");
|
|
14300
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
14301
|
+
}
|
|
14302
|
+
function table(rows, columns, origin, max = CELL_MAX) {
|
|
14303
|
+
if (rows.length === 0 || columns.length === 0) return "";
|
|
14304
|
+
return [columns.join(" | "), ...rows.map((row) => columns.map((key) => cell(row[key], origin, max)).join(" | "))].join("\n");
|
|
14305
|
+
}
|
|
14306
|
+
function findString(report, keys) {
|
|
14307
|
+
for (const key of keys) {
|
|
14308
|
+
const value = report[key];
|
|
14309
|
+
if (typeof value === "string") return value;
|
|
14310
|
+
}
|
|
14311
|
+
return null;
|
|
14312
|
+
}
|
|
14313
|
+
function dateRange$1(report) {
|
|
14314
|
+
const range = isObject(report.range) ? report.range : report;
|
|
14315
|
+
const from = findString(range, [
|
|
14316
|
+
"startDate",
|
|
14317
|
+
"from",
|
|
14318
|
+
"start"
|
|
14319
|
+
]);
|
|
14320
|
+
const to = findString(range, [
|
|
14321
|
+
"endDate",
|
|
14322
|
+
"to",
|
|
14323
|
+
"end"
|
|
14324
|
+
]);
|
|
14325
|
+
return from && to ? `${from} to ${to}` : null;
|
|
14326
|
+
}
|
|
14327
|
+
function limitsOf(report) {
|
|
14328
|
+
return [report.caveats, isObject(report.retainedEvidence) ? report.retainedEvidence.caveats : null].flatMap((list) => Array.isArray(list) ? list : []).filter((line) => typeof line === "string").filter((line) => !/^(Date window|Selection):/.test(line)).slice(0, 4).map((line) => line.length > 200 ? `${line.slice(0, 199)}…` : line);
|
|
14329
|
+
}
|
|
14330
|
+
function keyFacts(report) {
|
|
14331
|
+
if (!isObject(report.summary)) return null;
|
|
14332
|
+
const facts = Object.entries(report.summary).filter(([key, value]) => key !== "verdict" && isPrimitive(value) && value !== null).slice(0, 8).map(([key, value]) => `${key} ${cell(value, null)}`);
|
|
14333
|
+
return facts.length ? facts.join(", ") : null;
|
|
14334
|
+
}
|
|
14335
|
+
function filteredLine(report) {
|
|
14336
|
+
if (!isObject(report.selection)) return null;
|
|
14337
|
+
const counts = Object.entries(report.selection).filter(([key, value]) => typeof value === "number" && value > 0 && !/(source|aggregated|valid|eligible|returned|total|groups|days)/i.test(key)).map(([key, value]) => `${key} ${value}`);
|
|
14338
|
+
return counts.length ? `Filtered out: ${counts.join(", ")}.` : null;
|
|
14339
|
+
}
|
|
14340
|
+
function findingsFirst(report) {
|
|
14341
|
+
const last = Object.keys(report).filter((key) => /suppress|omitted|excluded/i.test(key));
|
|
14342
|
+
if (!last.length) return report;
|
|
14343
|
+
const out = {};
|
|
14344
|
+
for (const [key, value] of Object.entries(report)) if (!last.includes(key)) out[key] = value;
|
|
14345
|
+
for (const key of last) out[key] = report[key];
|
|
14346
|
+
return out;
|
|
14347
|
+
}
|
|
14348
|
+
function presentReport(input) {
|
|
14349
|
+
const { report, headline, origin } = input;
|
|
14350
|
+
if (!report) return {
|
|
14351
|
+
text: headline,
|
|
14352
|
+
data: null
|
|
14353
|
+
};
|
|
14354
|
+
if (input.detail === "full") {
|
|
14355
|
+
const ordered = findingsFirst(report);
|
|
14356
|
+
return {
|
|
14357
|
+
text: `${headline}\n\n${JSON.stringify(ordered)}`,
|
|
14358
|
+
data: ordered
|
|
14359
|
+
};
|
|
14360
|
+
}
|
|
14361
|
+
const list = mainList(report);
|
|
14362
|
+
const flat = list ? list.rows.map(flatten) : [];
|
|
14363
|
+
const head = [
|
|
14364
|
+
input.label,
|
|
14365
|
+
input.range ?? dateRange$1(report),
|
|
14366
|
+
findString(report, ["dataStatus"])
|
|
14367
|
+
].filter(Boolean).join(" · ");
|
|
14368
|
+
const facts = keyFacts(report);
|
|
14369
|
+
const limits = limitsOf(report);
|
|
14370
|
+
if (input.detail === "rows") {
|
|
14371
|
+
const page = flat.slice(input.offset, input.offset + input.limit);
|
|
14372
|
+
const columns = columnsFor(flat, 14);
|
|
14373
|
+
const end = input.offset + page.length;
|
|
14374
|
+
return {
|
|
14375
|
+
text: [
|
|
14376
|
+
head,
|
|
14377
|
+
list ? `${list.path}: rows ${input.offset + 1} to ${end} of ${flat.length}.` : "This report has no list of rows. Use detail \"full\".",
|
|
14378
|
+
table(page, columns, origin),
|
|
14379
|
+
end < flat.length ? `Next page: ${input.more("rows", end)}` : ""
|
|
14380
|
+
].filter(Boolean).join("\n\n"),
|
|
14381
|
+
data: {
|
|
14382
|
+
path: list?.path ?? null,
|
|
14383
|
+
total: flat.length,
|
|
14384
|
+
offset: input.offset,
|
|
14385
|
+
columns,
|
|
14386
|
+
rows: page
|
|
14387
|
+
}
|
|
14388
|
+
};
|
|
14389
|
+
}
|
|
14390
|
+
const shown = flat.slice(0, Math.min(input.limit, SUMMARY_ROWS));
|
|
14391
|
+
const columns = columnsFor(flat, 8);
|
|
14392
|
+
return {
|
|
14393
|
+
text: [
|
|
14394
|
+
head,
|
|
14395
|
+
headline,
|
|
14396
|
+
facts ? `Facts: ${facts}.` : "",
|
|
14397
|
+
...input.notes ?? [],
|
|
14398
|
+
flat.length < 5 ? filteredLine(report) : null,
|
|
14399
|
+
list ? `${list.path} (${shown.length} of ${flat.length} shown):\n${table(shown, columns, origin)}` : "",
|
|
14400
|
+
limits.length ? `Limits:\n${limits.map((line) => `- ${line}`).join("\n")}` : "",
|
|
14401
|
+
`More: ${shown.length < flat.length ? `${input.more("rows", shown.length)} for more rows, or ` : ""}${input.more("full")} for every field.`
|
|
14402
|
+
].filter(Boolean).join("\n\n"),
|
|
14403
|
+
data: {
|
|
14404
|
+
summary: isObject(report.summary) ? report.summary : null,
|
|
14405
|
+
path: list?.path ?? null,
|
|
14406
|
+
total: flat.length,
|
|
14407
|
+
columns,
|
|
14408
|
+
rows: shown,
|
|
14409
|
+
limits
|
|
14410
|
+
}
|
|
14411
|
+
};
|
|
14412
|
+
}
|
|
14413
|
+
function preview(report, origin, rows) {
|
|
14414
|
+
const list = mainList(report);
|
|
14415
|
+
if (!list) return {
|
|
14416
|
+
total: 0,
|
|
14417
|
+
text: ""
|
|
14418
|
+
};
|
|
14419
|
+
const flat = list.rows.map(flatten);
|
|
14420
|
+
return {
|
|
14421
|
+
total: flat.length,
|
|
14422
|
+
text: table(flat.slice(0, rows), columnsFor(flat, 6), origin)
|
|
14423
|
+
};
|
|
14424
|
+
}
|
|
14425
|
+
//#endregion
|
|
14426
|
+
//#region packages/core/dist/commands/sites.js
|
|
14427
|
+
async function readableSites(google, signIn) {
|
|
14428
|
+
return (await listSearchConsoleSites(google, { signIn })).filter((entry) => entry.permissionLevel !== "siteUnverifiedUser").map((entry) => ({
|
|
14429
|
+
siteUrl: entry.siteUrl,
|
|
14430
|
+
permissionLevel: entry.permissionLevel ?? ""
|
|
14431
|
+
})).sort((a, b) => a.siteUrl.localeCompare(b.siteUrl));
|
|
14432
|
+
}
|
|
14433
|
+
const hostOf$1 = (value) => {
|
|
14434
|
+
try {
|
|
14435
|
+
return new URL(value.includes("://") ? value : `https://${value.replace(/^sc-domain:/, "")}`).hostname.replace(/^www\./, "");
|
|
14436
|
+
} catch {
|
|
14437
|
+
return value;
|
|
14438
|
+
}
|
|
14439
|
+
};
|
|
14440
|
+
async function runSites(input) {
|
|
14441
|
+
const named = input.accounts.length > 1;
|
|
14442
|
+
const [google, bing] = await Promise.all([Promise.all(input.accounts.map(async (account) => {
|
|
14443
|
+
const [properties, analytics] = await Promise.all([readableSites(account.google, input.signIn).catch((error) => error instanceof Error ? error : new Error(String(error))), listAnalyticsAccounts(account.google, { signIn: input.signIn }).catch(() => null)]);
|
|
14444
|
+
return {
|
|
14445
|
+
email: account.email,
|
|
14446
|
+
properties,
|
|
14447
|
+
analytics
|
|
14448
|
+
};
|
|
14449
|
+
})), input.bing ? listBingSites(input.bing).catch((error) => error instanceof Error ? error : new Error(String(error))) : Promise.resolve(null)]);
|
|
14450
|
+
const bingSites = Array.isArray(bing) ? bing.filter((site) => site.verified) : [];
|
|
14451
|
+
const bingByHost = new Map(bingSites.map((site) => [hostOf$1(site.url), site]));
|
|
14452
|
+
const properties = google.flatMap((account) => account.properties instanceof Error ? [] : account.properties.map((property) => ({
|
|
14453
|
+
...property,
|
|
14454
|
+
...named ? { account: account.email ?? "" } : {},
|
|
14455
|
+
...Array.isArray(bing) ? { bing: bingByHost.get(hostOf$1(property.siteUrl))?.url ?? "" } : {}
|
|
14456
|
+
})));
|
|
14457
|
+
const googleHosts = new Set(properties.map((property) => hostOf$1(property.siteUrl)));
|
|
14458
|
+
const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf$1(site.url)));
|
|
14459
|
+
const failed = google.filter((account) => account.properties instanceof Error);
|
|
14460
|
+
const analytics = google.flatMap((account) => (account.analytics ?? []).flatMap((summary) => summary.propertySummaries.map((property) => ({
|
|
14461
|
+
property: property.property.replace("properties/", ""),
|
|
14462
|
+
name: property.displayName ?? "",
|
|
14463
|
+
account: summary.displayName ?? "",
|
|
14464
|
+
...named ? { signIn: account.email ?? "" } : {}
|
|
14465
|
+
}))));
|
|
14466
|
+
const research = [["dataforseo", "x-dataforseo-login"], ["ahrefs", "x-ahrefs-key"]].filter(([, name]) => input.keys.get(name ?? "")).map(([provider]) => provider);
|
|
14467
|
+
const columns = [
|
|
14468
|
+
"siteUrl",
|
|
14469
|
+
"permissionLevel",
|
|
14470
|
+
...named ? ["account"] : [],
|
|
14471
|
+
...Array.isArray(bing) ? ["bing"] : []
|
|
14472
|
+
];
|
|
14473
|
+
return {
|
|
14474
|
+
text: [
|
|
14475
|
+
named ? `Google accounts: ${input.accounts.map((account) => account.email).join(", ")}.` : null,
|
|
14476
|
+
`Search Console properties (${properties.length}):`,
|
|
14477
|
+
properties.length ? table(properties, columns, null) : "None. These Google sign-ins have no Search Console access, so the reports cannot run.",
|
|
14478
|
+
...failed.map((account) => `${account.email ?? "Google"}: could not list properties. ${account.properties.message}`),
|
|
14479
|
+
analytics.length ? `Google Analytics properties (${analytics.length}):\n${table(analytics, [
|
|
14480
|
+
"property",
|
|
14481
|
+
"name",
|
|
14482
|
+
"account",
|
|
14483
|
+
...named ? ["signIn"] : []
|
|
14484
|
+
], null)}` : "Google Analytics: no properties readable with these sign-ins.",
|
|
14485
|
+
input.bing === void 0 ? null : bing === null ? "Bing Webmaster: not connected." : bing instanceof Error ? `Bing Webmaster: could not list sites. ${bing.message}` : bingOnly.length ? `Bing Webmaster sites with no Search Console property here (${bingOnly.length}): ${bingOnly.map((site) => site.url).join(", ")}.` : `Bing Webmaster: ${bingSites.length} verified sites; the bing column shows each one.`,
|
|
14486
|
+
`Research keys: ${research.length ? research.join(", ") : "none"}.`,
|
|
14487
|
+
input.clicky?.length ? `Clicky sites (seo_data with source "clicky"): ${input.clicky.join(", ")}.` : null,
|
|
14488
|
+
`Add a Google account${input.bing === void 0 ? "" : ", Bing"} or a key: ${input.connect}`,
|
|
14489
|
+
"Next: seo_report with a site."
|
|
14490
|
+
].filter(Boolean).join("\n\n"),
|
|
14491
|
+
data: {
|
|
14492
|
+
searchConsole: properties,
|
|
14493
|
+
googleAnalytics: analytics,
|
|
14494
|
+
bing: Array.isArray(bing) ? bingSites : null,
|
|
14495
|
+
research,
|
|
14496
|
+
clicky: input.clicky ?? []
|
|
14497
|
+
}
|
|
14498
|
+
};
|
|
14499
|
+
}
|
|
14500
|
+
//#endregion
|
|
14501
|
+
//#region packages/core/dist/commands/linked.js
|
|
14502
|
+
const MAX_ROWS$1 = 1e5;
|
|
14503
|
+
const PERMISSION_RANK = {
|
|
14504
|
+
siteOwner: 0,
|
|
14505
|
+
siteFullUser: 1,
|
|
14506
|
+
siteRestrictedUser: 2
|
|
14507
|
+
};
|
|
14508
|
+
function bingRouter(calls) {
|
|
14509
|
+
if (calls.length === 0) return null;
|
|
14510
|
+
const only = calls[0];
|
|
14511
|
+
if (calls.length === 1 && only) return only;
|
|
14512
|
+
let lists;
|
|
14513
|
+
const sitesOf = () => {
|
|
14514
|
+
lists ??= Promise.all(calls.map(async (call) => ({
|
|
14515
|
+
call,
|
|
14516
|
+
sites: await call("GetUserSites").then((data) => Array.isArray(data) ? data : []).catch(() => [])
|
|
14517
|
+
})));
|
|
14518
|
+
return lists;
|
|
14519
|
+
};
|
|
14520
|
+
return async (method, params = {}) => {
|
|
14521
|
+
const all = await sitesOf();
|
|
14522
|
+
if (method === "GetUserSites") return all.flatMap((item) => item.sites);
|
|
14523
|
+
return (all.find((item) => item.sites.some((entry) => entry.IsVerified === true && entry.Url === params.siteUrl))?.call ?? only)(method, params);
|
|
14524
|
+
};
|
|
14525
|
+
}
|
|
14526
|
+
var LinkedAccounts = class {
|
|
14527
|
+
hint;
|
|
14528
|
+
google;
|
|
14529
|
+
bing;
|
|
14530
|
+
sites = /* @__PURE__ */ new Map();
|
|
14531
|
+
owners;
|
|
14532
|
+
constructor(input, hint) {
|
|
14533
|
+
this.hint = hint;
|
|
14534
|
+
this.google = input.google.map((account) => ({
|
|
14535
|
+
email: account.email,
|
|
14536
|
+
google: bearerFetch(account.token)
|
|
14537
|
+
}));
|
|
14538
|
+
this.bing = bingRouter(input.bing);
|
|
14539
|
+
}
|
|
14540
|
+
accounts() {
|
|
14541
|
+
return this.google.map((linked) => ({
|
|
14542
|
+
email: linked.email,
|
|
14543
|
+
google: linked.google
|
|
14544
|
+
}));
|
|
14545
|
+
}
|
|
14546
|
+
sitesOf(linked) {
|
|
14547
|
+
let found = this.sites.get(linked);
|
|
14548
|
+
if (!found) {
|
|
14549
|
+
found = readableSites(linked.google, this.hint.signIn);
|
|
14550
|
+
this.sites.set(linked, found);
|
|
14551
|
+
}
|
|
14552
|
+
return found;
|
|
14553
|
+
}
|
|
14554
|
+
ownerMap() {
|
|
14555
|
+
this.owners ??= (async () => {
|
|
14556
|
+
const lists = await Promise.all(this.google.map(async (linked) => ({
|
|
14557
|
+
linked,
|
|
14558
|
+
sites: await this.sitesOf(linked).catch((error) => {
|
|
14559
|
+
if (this.google.length === 1) throw error;
|
|
14560
|
+
return [];
|
|
14561
|
+
})
|
|
14562
|
+
})));
|
|
14563
|
+
const best = /* @__PURE__ */ new Map();
|
|
14564
|
+
for (const { linked, sites } of lists) for (const site of sites) {
|
|
14565
|
+
const rank = PERMISSION_RANK[site.permissionLevel] ?? 3;
|
|
14566
|
+
const found = best.get(site.siteUrl);
|
|
14567
|
+
if (!found || rank < found.rank) best.set(site.siteUrl, {
|
|
14568
|
+
linked,
|
|
14569
|
+
rank
|
|
14570
|
+
});
|
|
14571
|
+
}
|
|
14572
|
+
return new Map([...best].map(([site, { linked }]) => [site, linked]));
|
|
14573
|
+
})();
|
|
14574
|
+
return this.owners;
|
|
14575
|
+
}
|
|
14576
|
+
async resolve(site) {
|
|
14577
|
+
if (this.google.length === 0) throw new CommandError("NOT_CONNECTED", `No Google account is connected. ${this.hint.signIn}`);
|
|
14578
|
+
const found = matchProperty([...(await this.ownerMap()).keys()], site);
|
|
14579
|
+
if (found) return found;
|
|
14580
|
+
throw new CommandError("NO_ACCESS", `None of the connected Google accounts (${this.google.map((linked) => linked.email).join(", ")}) has a Search Console property for ${site}. ${this.hint.addGoogle}`);
|
|
14581
|
+
}
|
|
14582
|
+
async owner(site) {
|
|
14583
|
+
const owner = (await this.ownerMap()).get(await this.resolve(site));
|
|
14584
|
+
if (!owner) throw new CommandError("NO_ACCESS", `No access to ${site}.`);
|
|
14585
|
+
return owner;
|
|
14586
|
+
}
|
|
14587
|
+
searchAnalytics() {
|
|
14588
|
+
return async (site, body) => searchAnalyticsRows((await this.owner(site)).google, site, {
|
|
14589
|
+
...body,
|
|
14590
|
+
maxRows: body.maxRows ?? body.rowLimit ?? MAX_ROWS$1
|
|
14591
|
+
}, { signIn: this.hint.signIn });
|
|
14592
|
+
}
|
|
14593
|
+
async inspect(site, url) {
|
|
14594
|
+
return inspectSearchConsoleUrl((await this.owner(site)).google, site, url, {
|
|
14595
|
+
signIn: this.hint.signIn,
|
|
14596
|
+
languageCode: "en-US"
|
|
14597
|
+
});
|
|
14598
|
+
}
|
|
14599
|
+
};
|
|
14600
|
+
//#endregion
|
|
14601
|
+
//#region packages/core/dist/paths.js
|
|
14602
|
+
let memoizedPaths;
|
|
14603
|
+
function defaultConfigDir(input) {
|
|
14604
|
+
if (input.platform === "win32") return join(input.env.APPDATA ?? join(input.home, "AppData", "Roaming"), "seo", "Config");
|
|
14605
|
+
return join(input.env.XDG_CONFIG_HOME ?? join(input.home, ".config"), "seo");
|
|
14606
|
+
}
|
|
14607
|
+
function defaultCacheDir(input) {
|
|
14608
|
+
if (input.platform === "darwin") return join(input.home, "Library", "Caches", "seo");
|
|
14609
|
+
if (input.platform === "win32") return join(input.env.LOCALAPPDATA ?? join(input.home, "AppData", "Local"), "seo", "Cache");
|
|
14610
|
+
return join(input.env.XDG_CACHE_HOME ?? join(input.home, ".cache"), "seo");
|
|
14611
|
+
}
|
|
14612
|
+
function defaultLogDir(input) {
|
|
14613
|
+
if (input.platform === "darwin") return join(input.home, "Library", "Logs", "seo");
|
|
14614
|
+
if (input.platform === "win32") return join(input.env.LOCALAPPDATA ?? join(input.home, "AppData", "Local"), "seo", "Log");
|
|
14615
|
+
return join(input.env.XDG_STATE_HOME ?? join(input.home, ".local", "state"), "seo");
|
|
14616
|
+
}
|
|
14617
|
+
function resolvedPathOptions(input) {
|
|
14618
|
+
return {
|
|
14619
|
+
env: input.env ?? process.env,
|
|
14620
|
+
home: input.home ?? homedir(),
|
|
14621
|
+
platform: input.platform ?? process.platform
|
|
14622
|
+
};
|
|
14623
|
+
}
|
|
14624
|
+
function resolveSeoCliPaths(input = {}) {
|
|
14625
|
+
const options = resolvedPathOptions(input);
|
|
14626
|
+
const configDir = options.env.SEO_CONFIG_DIR ?? defaultConfigDir(options);
|
|
14627
|
+
const cacheDir = options.env.SEO_CACHE_DIR ?? defaultCacheDir(options);
|
|
14628
|
+
return {
|
|
14629
|
+
configDir,
|
|
14630
|
+
cacheDir,
|
|
14631
|
+
logDir: options.env.SEO_LOG_DIR ?? defaultLogDir(options),
|
|
14632
|
+
providerPackagesDir: join(configDir, "providers"),
|
|
14633
|
+
installedProviderPackagesFile: join(configDir, "provider-packages.json"),
|
|
14634
|
+
configFile: join(configDir, "config.json"),
|
|
14635
|
+
tokensFile: join(configDir, "tokens.json"),
|
|
14636
|
+
oauthClientFile: join(configDir, "oauth-client.json"),
|
|
14637
|
+
providerSecretsFile: join(configDir, "provider-secrets.json"),
|
|
14638
|
+
telemetryStateFile: join(configDir, "telemetry.json"),
|
|
14639
|
+
cacheDbFile: join(cacheDir, "cache.db")
|
|
14640
|
+
};
|
|
14641
|
+
}
|
|
14642
|
+
function legacyMacConfigDir(input) {
|
|
14643
|
+
const options = resolvedPathOptions(input);
|
|
14644
|
+
if (options.platform !== "darwin" || options.env.SEO_CONFIG_DIR || options.env.XDG_CONFIG_HOME) return;
|
|
14645
|
+
return join(options.home, "Library", "Preferences", "seo");
|
|
14646
|
+
}
|
|
14647
|
+
function migrateLegacyMacConfig(input = {}) {
|
|
14648
|
+
const legacyDir = legacyMacConfigDir(input);
|
|
14649
|
+
if (!legacyDir) return;
|
|
14650
|
+
const paths = resolveSeoCliPaths(input);
|
|
14651
|
+
if (!existsSync(legacyDir) || existsSync(paths.configDir)) return;
|
|
14652
|
+
try {
|
|
14653
|
+
mkdirSync(dirname(paths.configDir), {
|
|
14654
|
+
recursive: true,
|
|
14655
|
+
mode: 448
|
|
14656
|
+
});
|
|
14657
|
+
renameSync(legacyDir, paths.configDir);
|
|
14658
|
+
} catch (error) {
|
|
14659
|
+
const code = error.code;
|
|
14660
|
+
if (code !== "EEXIST" && code !== "ENOENT") throw error;
|
|
14661
|
+
}
|
|
14662
|
+
}
|
|
14663
|
+
function getSeoCliPaths() {
|
|
14664
|
+
if (memoizedPaths) return memoizedPaths;
|
|
14665
|
+
memoizedPaths = resolveSeoCliPaths();
|
|
14666
|
+
return memoizedPaths;
|
|
14667
|
+
}
|
|
14668
|
+
function ensureSeoCliDirs() {
|
|
14669
|
+
const paths = getSeoCliPaths();
|
|
14670
|
+
migrateLegacyMacConfig();
|
|
14671
|
+
mkdirSync(paths.configDir, {
|
|
14672
|
+
recursive: true,
|
|
14673
|
+
mode: 448
|
|
14674
|
+
});
|
|
14675
|
+
mkdirSync(paths.cacheDir, {
|
|
14676
|
+
recursive: true,
|
|
14677
|
+
mode: 448
|
|
14678
|
+
});
|
|
14679
|
+
mkdirSync(paths.logDir, {
|
|
14680
|
+
recursive: true,
|
|
14681
|
+
mode: 448
|
|
14682
|
+
});
|
|
14683
|
+
mkdirSync(paths.providerPackagesDir, {
|
|
14684
|
+
recursive: true,
|
|
14685
|
+
mode: 448
|
|
14686
|
+
});
|
|
14687
|
+
return paths;
|
|
14688
|
+
}
|
|
14689
|
+
//#endregion
|
|
14690
|
+
//#region packages/core/dist/storage/files.js
|
|
14691
|
+
function ensureParentDir(path, mode = 448) {
|
|
14692
|
+
mkdirSync(dirname(path), {
|
|
14693
|
+
recursive: true,
|
|
14694
|
+
mode
|
|
14695
|
+
});
|
|
14696
|
+
}
|
|
14697
|
+
function readJsonFile(path) {
|
|
14698
|
+
try {
|
|
14699
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
14700
|
+
} catch {
|
|
14701
|
+
return;
|
|
14702
|
+
}
|
|
14703
|
+
}
|
|
14704
|
+
function writeJsonAtomic(path, data, mode) {
|
|
14705
|
+
ensureParentDir(path);
|
|
14706
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
14707
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), {
|
|
14708
|
+
encoding: "utf8",
|
|
14709
|
+
mode
|
|
14710
|
+
});
|
|
14711
|
+
chmodSync(tmp, mode);
|
|
14712
|
+
renameSync(tmp, path);
|
|
14713
|
+
chmodSync(path, mode);
|
|
14714
|
+
}
|
|
14715
|
+
function safeRemove(path) {
|
|
14716
|
+
rmSync(path, {
|
|
14717
|
+
recursive: true,
|
|
14718
|
+
force: true
|
|
14719
|
+
});
|
|
14720
|
+
}
|
|
14721
|
+
//#endregion
|
|
14722
|
+
//#region packages/core/dist/cloud.js
|
|
14723
|
+
const CLOUD_URL = "https://seoskill.dev";
|
|
14724
|
+
const EARLY = 6e4;
|
|
14725
|
+
const linkFile = () => join(getSeoCliPaths().configDir, "cloud.json");
|
|
14726
|
+
function cloudToken() {
|
|
14727
|
+
const fromEnv = process.env.SEO_TOKEN?.trim();
|
|
14728
|
+
if (fromEnv) return fromEnv;
|
|
14729
|
+
return readJsonFile(linkFile())?.token;
|
|
14730
|
+
}
|
|
14731
|
+
function cloudLinked() {
|
|
14732
|
+
return Boolean(cloudToken());
|
|
14733
|
+
}
|
|
14734
|
+
const CLOUD_SIGN_IN = "Run `seo login` to link this machine to your seoskill.dev account again.";
|
|
14735
|
+
async function call$1(path, init = {}) {
|
|
14736
|
+
const response = await fetch(`${CLOUD_URL}${path}`, {
|
|
14737
|
+
...init,
|
|
14738
|
+
headers: {
|
|
14739
|
+
"content-type": "application/json",
|
|
14740
|
+
...init.token ? { authorization: `Bearer ${init.token}` } : {}
|
|
14741
|
+
}
|
|
14742
|
+
}).catch(() => {
|
|
14743
|
+
throw new SeoError("PROVIDER_UNAVAILABLE", `Could not reach ${CLOUD_URL}. Check the network and try again.`);
|
|
14744
|
+
});
|
|
14745
|
+
const body = await readJson(response, "seoskill.dev").catch(() => ({}));
|
|
14746
|
+
return {
|
|
14747
|
+
ok: response.ok,
|
|
14748
|
+
status: response.status,
|
|
14749
|
+
body
|
|
14750
|
+
};
|
|
14751
|
+
}
|
|
14752
|
+
let credentials;
|
|
14753
|
+
function expiresSoon(value) {
|
|
14754
|
+
return [...value.google, ...value.bing].some((item) => "expiresAt" in item && item.expiresAt - EARLY < Date.now());
|
|
14755
|
+
}
|
|
14756
|
+
async function cloudCredentials(options = {}) {
|
|
14757
|
+
const current = credentials ? await credentials.catch(() => null) : null;
|
|
14758
|
+
if (current && !expiresSoon(current) && !options.refreshClicky) return current;
|
|
14759
|
+
credentials = (async () => {
|
|
14760
|
+
const token = cloudToken();
|
|
14761
|
+
if (!token) throw new SeoError("AUTH_REQUIRED", notSignedIn());
|
|
14762
|
+
const result = await call$1(`/account/cli/credentials${options.refreshClicky ? "?clicky=refresh" : ""}`, { token });
|
|
14763
|
+
if (result.status === 401) throw new SeoError("AUTH_EXPIRED", `This machine is not linked to a seoskill.dev account any more. ${CLOUD_SIGN_IN}`);
|
|
14764
|
+
if (!result.ok) throw new SeoError("PROVIDER_UNAVAILABLE", `seoskill.dev returned ${result.status}. Try again in a minute.`);
|
|
14765
|
+
return result.body;
|
|
14766
|
+
})();
|
|
14767
|
+
return credentials;
|
|
14768
|
+
}
|
|
14769
|
+
function notSignedIn() {
|
|
14770
|
+
return "Not signed in. Run `seo login` to use your seoskill.dev account on this machine (it prints a link to approve), or `seo auth login` to sign in with Google on this machine only.";
|
|
14771
|
+
}
|
|
14772
|
+
function tokenOf(item, what) {
|
|
14773
|
+
if ("error" in item) throw new SeoError("AUTH_EXPIRED", `${what}: ${item.error}`);
|
|
14774
|
+
return item.token;
|
|
14775
|
+
}
|
|
14776
|
+
async function cloudGoogleToken(email) {
|
|
14777
|
+
const { google, account } = await cloudCredentials();
|
|
14778
|
+
const found = email ? google.find((item) => item.email.toLowerCase() === email.toLowerCase()) : google[0];
|
|
14779
|
+
if (!found) throw new SeoError("AUTH_REQUIRED", email ? `${email} is not linked to your seoskill.dev account. Add it at ${account}#google.` : `No Google account is linked to your seoskill.dev account. Add one at ${account}#google.`);
|
|
14780
|
+
return tokenOf(found, `Google sign-in for ${found.email}`);
|
|
14781
|
+
}
|
|
14782
|
+
async function cloudGoogleEmails() {
|
|
14783
|
+
return (await cloudCredentials()).google.map((item) => item.email);
|
|
14784
|
+
}
|
|
14785
|
+
let linked;
|
|
14786
|
+
async function cloudAccountFor(site) {
|
|
14787
|
+
if (!cloudLinked()) return void 0;
|
|
14788
|
+
linked ??= cloudGoogleEmails().then((emails) => new LinkedAccounts({
|
|
14789
|
+
google: emails.map((email) => ({
|
|
14790
|
+
email,
|
|
14791
|
+
token: () => cloudGoogleToken(email)
|
|
14792
|
+
})),
|
|
14793
|
+
bing: []
|
|
14794
|
+
}, {
|
|
14795
|
+
signIn: CLOUD_SIGN_IN,
|
|
14796
|
+
addGoogle: "Add it on your seoskill.dev account page."
|
|
14797
|
+
}));
|
|
14798
|
+
const accounts = await linked;
|
|
14799
|
+
if (accounts.google.length < 2) return accounts.google[0]?.email;
|
|
14800
|
+
return (await accounts.owner(site)).email;
|
|
14801
|
+
}
|
|
14802
|
+
async function cloudBing() {
|
|
14803
|
+
const { bing, account } = await cloudCredentials();
|
|
14804
|
+
return bingRouter(bing.map((_, index) => bingCall({ accessToken: async () => {
|
|
14805
|
+
const item = (await cloudCredentials()).bing[index];
|
|
14806
|
+
if (!item) throw new SeoError("AUTH_EXPIRED", CLOUD_SIGN_IN);
|
|
14807
|
+
return tokenOf(item, "Bing sign-in");
|
|
14808
|
+
} }, { signIn: `Reconnect Bing at ${account}.` })));
|
|
14809
|
+
}
|
|
14810
|
+
async function cloudKeys() {
|
|
14811
|
+
return (await cloudCredentials()).keys;
|
|
14812
|
+
}
|
|
14813
|
+
//#endregion
|
|
14814
|
+
//#region packages/core/dist/types/auth.js
|
|
14815
|
+
const tokenSchema = z.object({
|
|
14816
|
+
provider: z.literal("google"),
|
|
14817
|
+
account_email: z.string().email().max(320),
|
|
14818
|
+
scope: z.string().max(2e4),
|
|
14819
|
+
token_type: z.string().max(100),
|
|
14820
|
+
access_token: z.string().max(65536).optional(),
|
|
14821
|
+
refresh_token: z.string().max(65536).optional(),
|
|
14822
|
+
expires_at: z.number().int(),
|
|
14823
|
+
obtained_at: z.number().int(),
|
|
14824
|
+
client_source: z.enum(["shared", "byo"])
|
|
14825
|
+
});
|
|
14826
|
+
const tokenStoreSchema = z.object({
|
|
14827
|
+
version: z.literal(2),
|
|
14828
|
+
active_account: z.string().email(),
|
|
14829
|
+
accounts: z.array(tokenSchema).min(1).max(50)
|
|
14830
|
+
}).superRefine((store, context) => {
|
|
14831
|
+
const emails = store.accounts.map((tokens) => tokens.account_email.toLowerCase());
|
|
14832
|
+
if (new Set(emails).size !== emails.length) context.addIssue({
|
|
14833
|
+
code: "custom",
|
|
14834
|
+
path: ["accounts"],
|
|
14835
|
+
message: "Google account emails must be unique."
|
|
14836
|
+
});
|
|
14837
|
+
if (!emails.includes(store.active_account.toLowerCase())) context.addIssue({
|
|
14838
|
+
code: "custom",
|
|
14839
|
+
path: ["active_account"],
|
|
14840
|
+
message: "The active Google account must exist in accounts."
|
|
14841
|
+
});
|
|
14842
|
+
});
|
|
14843
|
+
//#endregion
|
|
14844
|
+
//#region packages/core/dist/providers/contracts.js
|
|
14845
|
+
const providerIdSchema = z.string().min(1).max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u);
|
|
14846
|
+
z.enum([
|
|
14847
|
+
"keyword-metrics",
|
|
14848
|
+
"keyword-discovery",
|
|
14849
|
+
"serp-snapshot",
|
|
14850
|
+
"domain-overview",
|
|
14851
|
+
"ranked-keywords",
|
|
14852
|
+
"relevant-pages",
|
|
14853
|
+
"serp-competitors",
|
|
14854
|
+
"link-summary",
|
|
14855
|
+
"referring-domains",
|
|
14856
|
+
"backlinks",
|
|
14857
|
+
"domain-rating",
|
|
14858
|
+
"local-search",
|
|
14859
|
+
"ai-mentions",
|
|
14860
|
+
"ai-prompt-observation"
|
|
14861
|
+
]);
|
|
14862
|
+
//#endregion
|
|
14863
|
+
//#region packages/core/dist/types/config.js
|
|
14864
|
+
const providerPreferenceSchema = z.enum(["cheap", "authoritative"]);
|
|
14865
|
+
z.enum(["google", "clicky"]);
|
|
14866
|
+
const analyticsExtensionIdSchema = z.string().min(1).max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u);
|
|
14867
|
+
const analyticsExtensionAccountSchema = z.record(z.string().min(1).max(64).regex(/^[A-Za-z][A-Za-z0-9-]*$/u).refine((value) => ![
|
|
14868
|
+
"constructor",
|
|
14869
|
+
"prototype",
|
|
14870
|
+
"__proto__"
|
|
14871
|
+
].includes(value)), z.string().trim().min(1).max(1e3)).refine((value) => Object.keys(value).length <= 16, { message: "An analytics provider account can have at most 16 fields." });
|
|
14872
|
+
const providerConnectionAccountsSchema = z.record(providerIdSchema, analyticsExtensionAccountSchema).refine((value) => Object.keys(value).length <= 20, { message: "There can be at most 20 provider connection records." });
|
|
14873
|
+
const analyticsConnectionSchema = z.discriminatedUnion("provider", [
|
|
14874
|
+
z.object({
|
|
14875
|
+
provider: z.literal("google"),
|
|
14876
|
+
propertyId: z.string(),
|
|
14877
|
+
accountEmail: z.string().email().optional()
|
|
14878
|
+
}),
|
|
14879
|
+
z.object({
|
|
14880
|
+
provider: z.literal("clicky"),
|
|
14881
|
+
siteId: z.string().regex(/^\d{1,30}$/u)
|
|
14882
|
+
}),
|
|
14883
|
+
z.object({
|
|
14884
|
+
provider: z.literal("extension"),
|
|
14885
|
+
providerId: analyticsExtensionIdSchema,
|
|
14886
|
+
account: analyticsExtensionAccountSchema
|
|
14887
|
+
})
|
|
14888
|
+
]);
|
|
14889
|
+
const configSchema = z.looseObject({
|
|
14890
|
+
providers: z.object({
|
|
14891
|
+
dataForSeoLogin: z.string().optional(),
|
|
14892
|
+
dataForSeoPassword: z.string().optional(),
|
|
14893
|
+
prefer: providerPreferenceSchema.default("cheap"),
|
|
14894
|
+
connections: providerConnectionAccountsSchema.optional()
|
|
14895
|
+
}).default({ prefer: "cheap" }),
|
|
14896
|
+
security: z.object({ useKeychain: z.boolean().default(true) }).default({ useKeychain: true }),
|
|
14897
|
+
auth: z.object({
|
|
14898
|
+
sharedClientId: z.string().optional(),
|
|
14899
|
+
sharedClientSecret: z.string().optional()
|
|
14900
|
+
}).default({})
|
|
14901
|
+
});
|
|
14902
|
+
let loadedKeyring;
|
|
14903
|
+
async function resolveKeyring() {
|
|
14904
|
+
if (!loadedKeyring) loadedKeyring = import("@napi-rs/keyring/keytar.js").then((module) => module);
|
|
14905
|
+
return loadedKeyring;
|
|
14906
|
+
}
|
|
14907
|
+
async function getKeyringPassword(service, account) {
|
|
14908
|
+
return (await resolveKeyring()).getPassword(service, account);
|
|
14909
|
+
}
|
|
14910
|
+
async function setKeyringPassword(service, account, password) {
|
|
14911
|
+
await (await resolveKeyring()).setPassword(service, account, password);
|
|
14912
|
+
}
|
|
14913
|
+
async function deleteKeyringPassword(service, account) {
|
|
14914
|
+
return (await resolveKeyring()).deletePassword(service, account);
|
|
14915
|
+
}
|
|
14916
|
+
//#endregion
|
|
14917
|
+
//#region packages/core/dist/storage/config.js
|
|
14918
|
+
const KEYRING_SERVICE$1 = "seo";
|
|
14919
|
+
const PRIVATE_FILE_MODE$1 = 384;
|
|
14920
|
+
function tightenConfigPermissions(path) {
|
|
14921
|
+
try {
|
|
14922
|
+
chmodSync(path, PRIVATE_FILE_MODE$1);
|
|
14923
|
+
} catch (error) {
|
|
14924
|
+
if (error.code !== "ENOENT") throw error;
|
|
14925
|
+
}
|
|
14926
|
+
}
|
|
14927
|
+
function readConfig() {
|
|
14928
|
+
ensureSeoCliDirs();
|
|
14929
|
+
const configFile = getSeoCliPaths().configFile;
|
|
14930
|
+
tightenConfigPermissions(configFile);
|
|
14931
|
+
const raw = readJsonFile(configFile);
|
|
14932
|
+
return configSchema.parse(raw ?? {});
|
|
14933
|
+
}
|
|
14934
|
+
function writeConfig(config) {
|
|
14935
|
+
ensureSeoCliDirs();
|
|
14936
|
+
writeJsonAtomic(getSeoCliPaths().configFile, configSchema.parse(config), PRIVATE_FILE_MODE$1);
|
|
14937
|
+
}
|
|
14938
|
+
function getKeyringAccount(tokens) {
|
|
14939
|
+
return `google:${tokens.account_email}`;
|
|
14940
|
+
}
|
|
14941
|
+
function hasTokenSecrets(tokens) {
|
|
14942
|
+
return Boolean(tokens.access_token || tokens.refresh_token);
|
|
14943
|
+
}
|
|
14944
|
+
function tokenMetadata(tokens) {
|
|
14945
|
+
return {
|
|
14946
|
+
...tokens,
|
|
14947
|
+
access_token: void 0,
|
|
14948
|
+
refresh_token: void 0
|
|
14949
|
+
};
|
|
14950
|
+
}
|
|
14951
|
+
function sameAccount(left, right) {
|
|
14952
|
+
return left.toLowerCase() === right.toLowerCase();
|
|
14953
|
+
}
|
|
14954
|
+
function parseTokenStore(raw) {
|
|
14955
|
+
if (raw === void 0 || raw === null) return void 0;
|
|
14956
|
+
const store = tokenStoreSchema.safeParse(raw);
|
|
14957
|
+
if (store.success) return store.data;
|
|
14958
|
+
const legacy = tokenSchema.safeParse(raw);
|
|
14959
|
+
if (!legacy.success) return tokenStoreSchema.parse(raw);
|
|
14960
|
+
return {
|
|
14961
|
+
version: 2,
|
|
14962
|
+
active_account: legacy.data.account_email,
|
|
14963
|
+
accounts: [legacy.data]
|
|
14964
|
+
};
|
|
14965
|
+
}
|
|
14966
|
+
function readRawTokenStore() {
|
|
14967
|
+
ensureSeoCliDirs();
|
|
14968
|
+
return parseTokenStore(readJsonFile(getSeoCliPaths().tokensFile));
|
|
14969
|
+
}
|
|
14970
|
+
function writeTokenStore(store) {
|
|
14971
|
+
writeJsonAtomic(getSeoCliPaths().tokensFile, tokenStoreSchema.parse(store), PRIVATE_FILE_MODE$1);
|
|
14972
|
+
}
|
|
14973
|
+
async function readKeyringTokens(tokens) {
|
|
14974
|
+
const account = getKeyringAccount(tokens);
|
|
14975
|
+
const [accessToken, refreshToken] = await Promise.all([getKeyringPassword(KEYRING_SERVICE$1, `${account}:access`), getKeyringPassword(KEYRING_SERVICE$1, `${account}:refresh`)]);
|
|
14976
|
+
return {
|
|
14977
|
+
accessToken: accessToken ?? void 0,
|
|
14978
|
+
refreshToken: refreshToken ?? void 0
|
|
14979
|
+
};
|
|
14980
|
+
}
|
|
14981
|
+
async function writeKeyringTokens(tokens) {
|
|
14982
|
+
const account = getKeyringAccount(tokens);
|
|
14983
|
+
if (tokens.access_token) await setKeyringPassword(KEYRING_SERVICE$1, `${account}:access`, tokens.access_token);
|
|
14984
|
+
else await deleteKeyringPassword(KEYRING_SERVICE$1, `${account}:access`);
|
|
14985
|
+
if (tokens.refresh_token) await setKeyringPassword(KEYRING_SERVICE$1, `${account}:refresh`, tokens.refresh_token);
|
|
14986
|
+
else await deleteKeyringPassword(KEYRING_SERVICE$1, `${account}:refresh`);
|
|
14987
|
+
}
|
|
14988
|
+
async function deleteKeyringTokens(tokens) {
|
|
14989
|
+
const account = getKeyringAccount(tokens);
|
|
14990
|
+
await Promise.all([deleteKeyringPassword(KEYRING_SERVICE$1, `${account}:access`), deleteKeyringPassword(KEYRING_SERVICE$1, `${account}:refresh`)]);
|
|
14991
|
+
}
|
|
14992
|
+
async function hydrateTokens(tokens) {
|
|
14993
|
+
if (!readConfig().security.useKeychain || hasTokenSecrets(tokens)) return tokens;
|
|
14994
|
+
try {
|
|
14995
|
+
const keyringTokens = await readKeyringTokens(tokens);
|
|
14996
|
+
return {
|
|
14997
|
+
...tokens,
|
|
14998
|
+
access_token: keyringTokens.accessToken,
|
|
14999
|
+
refresh_token: keyringTokens.refreshToken
|
|
15000
|
+
};
|
|
15001
|
+
} catch {
|
|
15002
|
+
return tokens;
|
|
15003
|
+
}
|
|
15004
|
+
}
|
|
15005
|
+
async function readTokens(accountEmail) {
|
|
15006
|
+
const store = readRawTokenStore();
|
|
15007
|
+
if (!store) return void 0;
|
|
15008
|
+
const selected = accountEmail ? store.accounts.find((tokens) => sameAccount(tokens.account_email, accountEmail)) : store.accounts.find((tokens) => sameAccount(tokens.account_email, store.active_account));
|
|
15009
|
+
return selected ? hydrateTokens(selected) : void 0;
|
|
14070
15010
|
}
|
|
14071
15011
|
async function writeTokens(tokens, options = {}) {
|
|
14072
15012
|
ensureSeoCliDirs();
|
|
@@ -14401,7 +15341,7 @@ async function requestGoogleAccessToken(input, fetchImpl = fetch) {
|
|
|
14401
15341
|
//#region packages/core/dist/gsc/auth/authorized-client.js
|
|
14402
15342
|
const REFRESH_BUFFER_MS = 6e4;
|
|
14403
15343
|
function authRequired(accountEmail) {
|
|
14404
|
-
return new SeoError("AUTH_REQUIRED", accountEmail ? `Google account ${accountEmail} is not connected. Run \`seo auth login\` first.` :
|
|
15344
|
+
return new SeoError("AUTH_REQUIRED", accountEmail ? `Google account ${accountEmail} is not connected. Run \`seo auth login\` first.` : notSignedIn());
|
|
14405
15345
|
}
|
|
14406
15346
|
function authExpired() {
|
|
14407
15347
|
return new SeoError("AUTH_EXPIRED", "Google refresh token is no longer valid. Run `seo auth login` again.");
|
|
@@ -14494,6 +15434,18 @@ async function createGoogleAccessTokenClient(accountEmail) {
|
|
|
14494
15434
|
accountEmail: serviceAccount.clientEmail
|
|
14495
15435
|
}
|
|
14496
15436
|
};
|
|
15437
|
+
if (cloudLinked()) {
|
|
15438
|
+
const email = accountEmail ?? (await cloudGoogleEmails())[0] ?? "";
|
|
15439
|
+
return {
|
|
15440
|
+
client: { getAccessToken: () => cloudGoogleToken(email || void 0) },
|
|
15441
|
+
mode: "cloud",
|
|
15442
|
+
identity: email,
|
|
15443
|
+
quotaIdentity: {
|
|
15444
|
+
clientId: "seoskill.dev",
|
|
15445
|
+
accountEmail: email
|
|
15446
|
+
}
|
|
15447
|
+
};
|
|
15448
|
+
}
|
|
14497
15449
|
const { client, tokens } = await createAuthorizedClient(accountEmail);
|
|
14498
15450
|
const clientConfig = requireClientConfig(tokens);
|
|
14499
15451
|
return {
|
|
@@ -18327,7 +19279,7 @@ function inspectionResponseError(response, input) {
|
|
|
18327
19279
|
}
|
|
18328
19280
|
async function inspectUrl(input) {
|
|
18329
19281
|
const inspectionUrl = assertUrlMatchesGscProperty(input.siteUrl, input.inspectionUrl);
|
|
18330
|
-
const { client, quotaIdentity } = await getAuthorized(input.accountEmail);
|
|
19282
|
+
const { client, quotaIdentity } = await getAuthorized(input.accountEmail ?? await cloudAccountFor(input.siteUrl));
|
|
18331
19283
|
const reservation = reserveUrlInspectionQuota({
|
|
18332
19284
|
credentialKey: credentialKey(quotaIdentity),
|
|
18333
19285
|
property: input.siteUrl,
|
|
@@ -18377,35 +19329,6 @@ async function inspectUrl(input) {
|
|
|
18377
19329
|
return await readJson(response, "URL Inspection");
|
|
18378
19330
|
}
|
|
18379
19331
|
//#endregion
|
|
18380
|
-
//#region packages/core/dist/gsc/dates.js
|
|
18381
|
-
const GSC_TIME_ZONE = "America/Los_Angeles";
|
|
18382
|
-
function dateInTimeZone(now, timeZone) {
|
|
18383
|
-
const parts = Object.fromEntries(new Intl.DateTimeFormat("en-US", {
|
|
18384
|
-
timeZone,
|
|
18385
|
-
year: "numeric",
|
|
18386
|
-
month: "2-digit",
|
|
18387
|
-
day: "2-digit"
|
|
18388
|
-
}).formatToParts(now).map((part) => [part.type, part.value]));
|
|
18389
|
-
return new Date(Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day)));
|
|
18390
|
-
}
|
|
18391
|
-
function shiftIsoDate(date, days) {
|
|
18392
|
-
const value = /* @__PURE__ */ new Date(`${date}T00:00:00.000Z`);
|
|
18393
|
-
value.setUTCDate(value.getUTCDate() + days);
|
|
18394
|
-
return value.toISOString().slice(0, 10);
|
|
18395
|
-
}
|
|
18396
|
-
function latestFinalGscDate(now = /* @__PURE__ */ new Date()) {
|
|
18397
|
-
const pacificToday = dateInTimeZone(now, GSC_TIME_ZONE);
|
|
18398
|
-
pacificToday.setUTCDate(pacificToday.getUTCDate() - 4);
|
|
18399
|
-
return pacificToday.toISOString().slice(0, 10);
|
|
18400
|
-
}
|
|
18401
|
-
function finalGscDateRange(days = 28, now = /* @__PURE__ */ new Date()) {
|
|
18402
|
-
const endDate = latestFinalGscDate(now);
|
|
18403
|
-
return {
|
|
18404
|
-
startDate: shiftIsoDate(endDate, -(days - 1)),
|
|
18405
|
-
endDate
|
|
18406
|
-
};
|
|
18407
|
-
}
|
|
18408
|
-
//#endregion
|
|
18409
19332
|
//#region packages/core/dist/gsc/client/search-analytics.js
|
|
18410
19333
|
function searchDateRange(days) {
|
|
18411
19334
|
return finalGscDateRange(days);
|
|
@@ -18414,7 +19337,7 @@ function resolveSearchDateWindow(input = 28) {
|
|
|
18414
19337
|
return typeof input === "number" ? searchDateRange(input) : input;
|
|
18415
19338
|
}
|
|
18416
19339
|
async function querySearchAnalytics(site, body, opts = {}) {
|
|
18417
|
-
const { client, identity } = await getAuthorized(opts.accountEmail);
|
|
19340
|
+
const { client, identity } = await getAuthorized(opts.accountEmail ?? await cloudAccountFor(site));
|
|
18418
19341
|
const db = getDb();
|
|
18419
19342
|
const queryHash = hashKey([
|
|
18420
19343
|
site,
|
|
@@ -18573,12 +19496,21 @@ async function queryPagesTopQueriesBatch(site, pageUrls, range = 28) {
|
|
|
18573
19496
|
//#endregion
|
|
18574
19497
|
//#region packages/core/dist/gsc/client/sites.js
|
|
18575
19498
|
async function listSites(_refresh = false, accountEmail) {
|
|
18576
|
-
const
|
|
18577
|
-
const entries = await listSearchConsoleSites(googleFetchFor(client), LOCAL_SIGN_IN);
|
|
19499
|
+
const entries = !accountEmail && cloudLinked() ? await allCloudSites() : await listSearchConsoleSites(googleFetchFor((await getAuthorized(accountEmail)).client), LOCAL_SIGN_IN);
|
|
18578
19500
|
const insert = getDb().prepare("INSERT OR REPLACE INTO sites (site_url, display_name, permission, added_at, is_default) VALUES (?, ?, ?, ?, COALESCE((SELECT is_default FROM sites WHERE site_url = ?), 0))");
|
|
18579
19501
|
for (const site of entries) insert.run(site.siteUrl, site.siteUrl, site.permissionLevel ?? null, Date.now(), site.siteUrl);
|
|
18580
19502
|
return entries;
|
|
18581
19503
|
}
|
|
19504
|
+
async function allCloudSites() {
|
|
19505
|
+
const lists = await Promise.all((await cloudGoogleEmails()).map(async (email) => listSearchConsoleSites(googleFetchFor((await getAuthorized(email)).client), LOCAL_SIGN_IN)));
|
|
19506
|
+
const best = /* @__PURE__ */ new Map();
|
|
19507
|
+
const rank = (site) => PERMISSION_RANK[site.permissionLevel ?? ""] ?? 3;
|
|
19508
|
+
for (const site of lists.flat()) {
|
|
19509
|
+
const found = best.get(site.siteUrl);
|
|
19510
|
+
if (!found || rank(site) < rank(found)) best.set(site.siteUrl, site);
|
|
19511
|
+
}
|
|
19512
|
+
return [...best.values()];
|
|
19513
|
+
}
|
|
18582
19514
|
//#endregion
|
|
18583
19515
|
//#region packages/core/dist/analyze/page-technical-signals.js
|
|
18584
19516
|
function normalizePageUrl(value) {
|
|
@@ -19270,7 +20202,7 @@ function jaccard(a, b) {
|
|
|
19270
20202
|
const union = /* @__PURE__ */ new Set([...setA, ...setB]);
|
|
19271
20203
|
return union.size === 0 ? 0 : intersection.length / union.size;
|
|
19272
20204
|
}
|
|
19273
|
-
function defaultDateRange
|
|
20205
|
+
function defaultDateRange(days = 28, now = /* @__PURE__ */ new Date()) {
|
|
19274
20206
|
return finalGscDateRange(days, now);
|
|
19275
20207
|
}
|
|
19276
20208
|
//#endregion
|
|
@@ -20319,7 +21251,7 @@ function reportCaveats$1(input) {
|
|
|
20319
21251
|
async function pageOpportunitiesReport(input, dependencies = defaultDependencies$12) {
|
|
20320
21252
|
const url = assertUrlMatchesGscProperty(input.site, input.url);
|
|
20321
21253
|
const days = reportDays$1(input.days);
|
|
20322
|
-
const range = defaultDateRange
|
|
21254
|
+
const range = defaultDateRange(days);
|
|
20323
21255
|
const targetResult = await dependencies.searchAnalytics(input.site, {
|
|
20324
21256
|
...range,
|
|
20325
21257
|
dimensions: ["query", "page"],
|
|
@@ -23369,37 +24301,10 @@ function collectCrawlAiSignals(input) {
|
|
|
23369
24301
|
]);
|
|
23370
24302
|
}
|
|
23371
24303
|
//#endregion
|
|
23372
|
-
//#region packages/core/dist/analyze/site-diagnostics/quick-wins-report-input.js
|
|
23373
|
-
function integerOption$1(input) {
|
|
23374
|
-
if (input.value === void 0) return input.fallback;
|
|
23375
|
-
if (!Number.isInteger(input.value) || input.value < input.minimum || input.value > input.maximum) throw new SeoError("INVALID_INPUT", `${input.label} must be a whole number between ${input.minimum} and ${input.maximum}.`);
|
|
23376
|
-
return input.value;
|
|
23377
|
-
}
|
|
23378
|
-
function defaultDateRange(days, now) {
|
|
23379
|
-
return finalGscDateRange(days, now);
|
|
23380
|
-
}
|
|
23381
|
-
function explicitDateRange(input, maximumDays) {
|
|
23382
|
-
if (!input.startDate && !input.endDate) return void 0;
|
|
23383
|
-
if (!input.startDate || !input.endDate) throw new SeoError("INVALID_INPUT", "Start date and end date must be provided together.");
|
|
23384
|
-
const pattern = /^\d{4}-\d{2}-\d{2}$/;
|
|
23385
|
-
const start = /* @__PURE__ */ new Date(`${input.startDate}T00:00:00.000Z`);
|
|
23386
|
-
const end = /* @__PURE__ */ new Date(`${input.endDate}T00:00:00.000Z`);
|
|
23387
|
-
if (!pattern.test(input.startDate) || !pattern.test(input.endDate) || Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || start.toISOString().slice(0, 10) !== input.startDate || end.toISOString().slice(0, 10) !== input.endDate || start > end) throw new SeoError("INVALID_INPUT", "Start date and end date must be valid YYYY-MM-DD values in ascending order.");
|
|
23388
|
-
const days = Math.floor((end.getTime() - start.getTime()) / 864e5) + 1;
|
|
23389
|
-
if (days > maximumDays) throw new SeoError("INVALID_INPUT", `Date range must not exceed ${maximumDays} days.`);
|
|
23390
|
-
return {
|
|
23391
|
-
range: {
|
|
23392
|
-
startDate: input.startDate,
|
|
23393
|
-
endDate: input.endDate
|
|
23394
|
-
},
|
|
23395
|
-
days
|
|
23396
|
-
};
|
|
23397
|
-
}
|
|
23398
|
-
//#endregion
|
|
23399
24304
|
//#region packages/core/dist/analyze/crawler/site-crawl-providers.js
|
|
23400
24305
|
function crawlMetricsWindow(now) {
|
|
23401
24306
|
return {
|
|
23402
|
-
...defaultDateRange(28, now),
|
|
24307
|
+
...defaultDateRange$1(28, now),
|
|
23403
24308
|
days: 28
|
|
23404
24309
|
};
|
|
23405
24310
|
}
|
|
@@ -24728,6 +25633,9 @@ async function storedSiteKeys() {
|
|
|
24728
25633
|
source: credential.source
|
|
24729
25634
|
};
|
|
24730
25635
|
}
|
|
25636
|
+
async function readClickySiteKeys() {
|
|
25637
|
+
return (await storedSiteKeys()).siteKeys;
|
|
25638
|
+
}
|
|
24731
25639
|
async function readClickySiteKey(siteIdValue, input = {}) {
|
|
24732
25640
|
const siteId = normalizeClickySiteId(siteIdValue);
|
|
24733
25641
|
const environmentKey = (input.env ?? process.env)[CLICKY_SITEKEY_ENV]?.trim();
|
|
@@ -26813,7 +27721,7 @@ async function ctrUnderperformersReportWith(input, dependencies) {
|
|
|
26813
27721
|
label: "Days"
|
|
26814
27722
|
});
|
|
26815
27723
|
const now = dependencies.now();
|
|
26816
|
-
const range = explicitRange?.range ?? defaultDateRange(days, now);
|
|
27724
|
+
const range = explicitRange?.range ?? defaultDateRange$1(days, now);
|
|
26817
27725
|
const source = await dependencies.searchAnalytics(input.site, {
|
|
26818
27726
|
...range,
|
|
26819
27727
|
dimensions: ["query", "page"],
|
|
@@ -27255,7 +28163,7 @@ function analyzeSegmentRows(input) {
|
|
|
27255
28163
|
//#region packages/core/dist/analyze/segment-impact-input.js
|
|
27256
28164
|
const DAY_MS = 864e5;
|
|
27257
28165
|
const MAX_WINDOW_DAYS = 240;
|
|
27258
|
-
function subtractDays
|
|
28166
|
+
function subtractDays(value, days) {
|
|
27259
28167
|
const date = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
27260
28168
|
date.setUTCDate(date.getUTCDate() - days);
|
|
27261
28169
|
return date.toISOString().slice(0, 10);
|
|
@@ -27264,9 +28172,9 @@ function segmentRangeDays(range) {
|
|
|
27264
28172
|
return Math.floor((Date.parse(`${range.endDate}T00:00:00.000Z`) - Date.parse(`${range.startDate}T00:00:00.000Z`)) / DAY_MS) + 1;
|
|
27265
28173
|
}
|
|
27266
28174
|
function segmentComparisonRange(after, compareDays) {
|
|
27267
|
-
const endDate = subtractDays
|
|
28175
|
+
const endDate = subtractDays(after.startDate, 1);
|
|
27268
28176
|
return {
|
|
27269
|
-
startDate: subtractDays
|
|
28177
|
+
startDate: subtractDays(endDate, compareDays - 1),
|
|
27270
28178
|
endDate
|
|
27271
28179
|
};
|
|
27272
28180
|
}
|
|
@@ -27278,7 +28186,7 @@ function resolveSegmentRanges(input) {
|
|
|
27278
28186
|
maximum: MAX_WINDOW_DAYS,
|
|
27279
28187
|
label: "days"
|
|
27280
28188
|
});
|
|
27281
|
-
const after = explicitDateRange(input, MAX_WINDOW_DAYS)?.range ?? defaultDateRange(days, input.now);
|
|
28189
|
+
const after = explicitDateRange(input, MAX_WINDOW_DAYS)?.range ?? defaultDateRange$1(days, input.now);
|
|
27282
28190
|
const compareDays = integerOption$1({
|
|
27283
28191
|
value: input.compareDays,
|
|
27284
28192
|
fallback: segmentRangeDays(after),
|
|
@@ -27297,63 +28205,6 @@ function validateSegmentSite(site) {
|
|
|
27297
28205
|
if (!site.trim()) throw new SeoError("INVALID_INPUT", "site must not be empty.");
|
|
27298
28206
|
}
|
|
27299
28207
|
//#endregion
|
|
27300
|
-
//#region packages/core/dist/analyze/site-diagnostics/decay-report-input.js
|
|
27301
|
-
function decayNumberOption(input) {
|
|
27302
|
-
const value = input.value ?? input.fallback;
|
|
27303
|
-
if (!Number.isFinite(value) || value < input.minimum || value > input.maximum) throw new SeoError("INVALID_INPUT", `${input.label} must be between ${input.minimum} and ${input.maximum}.`);
|
|
27304
|
-
return value;
|
|
27305
|
-
}
|
|
27306
|
-
function rangeDays$1(range) {
|
|
27307
|
-
return Math.floor(((/* @__PURE__ */ new Date(`${range.endDate}T00:00:00.000Z`)).getTime() - (/* @__PURE__ */ new Date(`${range.startDate}T00:00:00.000Z`)).getTime()) / 864e5) + 1;
|
|
27308
|
-
}
|
|
27309
|
-
function dateString(date) {
|
|
27310
|
-
return date.toISOString().slice(0, 10);
|
|
27311
|
-
}
|
|
27312
|
-
function subtractDays(value, days) {
|
|
27313
|
-
const date = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
27314
|
-
date.setUTCDate(date.getUTCDate() - days);
|
|
27315
|
-
return dateString(date);
|
|
27316
|
-
}
|
|
27317
|
-
function priorYearDate(value) {
|
|
27318
|
-
const source = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
27319
|
-
const year = source.getUTCFullYear() - 1;
|
|
27320
|
-
const month = source.getUTCMonth();
|
|
27321
|
-
const day = source.getUTCDate();
|
|
27322
|
-
const candidate = new Date(Date.UTC(year, month, day));
|
|
27323
|
-
if (candidate.getUTCMonth() !== month) return dateString(new Date(Date.UTC(year, month + 1, 0)));
|
|
27324
|
-
return dateString(candidate);
|
|
27325
|
-
}
|
|
27326
|
-
function subtractCalendarMonths(date, months) {
|
|
27327
|
-
const day = date.getUTCDate();
|
|
27328
|
-
const target = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() - months, 1));
|
|
27329
|
-
const lastDay = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate();
|
|
27330
|
-
target.setUTCDate(Math.min(day, lastDay));
|
|
27331
|
-
return dateString(target);
|
|
27332
|
-
}
|
|
27333
|
-
function validateDecayRanges(input) {
|
|
27334
|
-
const latestFinalDate = defaultDateRange(1, input.now).endDate;
|
|
27335
|
-
if (input.current.endDate > latestFinalDate) throw new SeoError("INVALID_INPUT", `Decay end date must be ${latestFinalDate} or earlier so both GSC windows use final data.`);
|
|
27336
|
-
const pacificToday = /* @__PURE__ */ new Date(`${latestFinalDate}T00:00:00.000Z`);
|
|
27337
|
-
pacificToday.setUTCDate(pacificToday.getUTCDate() + 4);
|
|
27338
|
-
const retentionStart = subtractCalendarMonths(pacificToday, 16);
|
|
27339
|
-
if (input.previous.startDate < retentionStart) throw new SeoError("INVALID_INPUT", `Comparison range starts before Search Console's rolling 16-month API history (${retentionStart}). Choose a shorter range or use previously stored data.`);
|
|
27340
|
-
}
|
|
27341
|
-
function decayComparisonRange(current, comparison = "previous-period") {
|
|
27342
|
-
const days = rangeDays$1(current);
|
|
27343
|
-
if (comparison === "year-over-year") {
|
|
27344
|
-
const endDate = priorYearDate(current.endDate);
|
|
27345
|
-
return {
|
|
27346
|
-
startDate: subtractDays(endDate, days - 1),
|
|
27347
|
-
endDate
|
|
27348
|
-
};
|
|
27349
|
-
}
|
|
27350
|
-
const endDate = subtractDays(current.startDate, 1);
|
|
27351
|
-
return {
|
|
27352
|
-
startDate: subtractDays(endDate, days - 1),
|
|
27353
|
-
endDate
|
|
27354
|
-
};
|
|
27355
|
-
}
|
|
27356
|
-
//#endregion
|
|
27357
28208
|
//#region packages/core/dist/analyze/segment-impact-fallback.js
|
|
27358
28209
|
function unavailableSegmentImpactReport(input) {
|
|
27359
28210
|
const days = segmentRangeDays(input.after);
|
|
@@ -29173,7 +30024,7 @@ async function cannibalReportWith(input, dependencies) {
|
|
|
29173
30024
|
});
|
|
29174
30025
|
const now = dependencies.now();
|
|
29175
30026
|
const explicit = explicitDateRange(input, 548);
|
|
29176
|
-
const range = explicit?.range ?? defaultDateRange(days, now);
|
|
30027
|
+
const range = explicit?.range ?? defaultDateRange$1(days, now);
|
|
29177
30028
|
const rangeDays = explicit?.days ?? days;
|
|
29178
30029
|
const requestBase = {
|
|
29179
30030
|
...range,
|
|
@@ -29631,7 +30482,7 @@ async function decayingReportWith(input, dependencies) {
|
|
|
29631
30482
|
});
|
|
29632
30483
|
const now = dependencies.now();
|
|
29633
30484
|
const explicit = explicitDateRange(input, 548);
|
|
29634
|
-
const currentRange = explicit?.range ?? defaultDateRange(days, now);
|
|
30485
|
+
const currentRange = explicit?.range ?? defaultDateRange$1(days, now);
|
|
29635
30486
|
const comparison = input.comparison ?? "previous-period";
|
|
29636
30487
|
const previousRange = decayComparisonRange(currentRange, comparison);
|
|
29637
30488
|
validateDecayRanges({
|
|
@@ -30104,7 +30955,7 @@ async function quickWinsReportWith(input, dependencies) {
|
|
|
30104
30955
|
label: "Verification limit"
|
|
30105
30956
|
});
|
|
30106
30957
|
const generatedAt = dependencies.now().toISOString();
|
|
30107
|
-
const range = explicitRange?.range ?? defaultDateRange(days, new Date(generatedAt));
|
|
30958
|
+
const range = explicitRange?.range ?? defaultDateRange$1(days, new Date(generatedAt));
|
|
30108
30959
|
const source = await dependencies.searchAnalytics(input.site, {
|
|
30109
30960
|
...range,
|
|
30110
30961
|
dimensions: ["query", "page"],
|
|
@@ -30528,7 +31379,7 @@ function reportDays(value) {
|
|
|
30528
31379
|
if (!Number.isInteger(value) || value < 1 || value > MAX_DAYS) throw new SeoError("INVALID_INPUT", `Days must be a whole number between 1 and ${MAX_DAYS}.`);
|
|
30529
31380
|
return value;
|
|
30530
31381
|
}
|
|
30531
|
-
function dateRange
|
|
31382
|
+
function dateRange(days, now) {
|
|
30532
31383
|
return finalGscDateRange(days, now);
|
|
30533
31384
|
}
|
|
30534
31385
|
function verifiedRecommendation(coverage) {
|
|
@@ -30568,7 +31419,7 @@ async function strikingDistanceWith(input, dependencies) {
|
|
|
30568
31419
|
const explicitRange = explicitDateRange(input, MAX_DAYS);
|
|
30569
31420
|
const days = explicitRange?.days ?? reportDays(input.days);
|
|
30570
31421
|
const generatedAt = dependencies.now().toISOString();
|
|
30571
|
-
const range = explicitRange?.range ?? dateRange
|
|
31422
|
+
const range = explicitRange?.range ?? dateRange(days, new Date(generatedAt));
|
|
30572
31423
|
const result = await dependencies.searchAnalytics(input.site, {
|
|
30573
31424
|
...range,
|
|
30574
31425
|
dimensions: ["query", "page"],
|
|
@@ -30680,44 +31531,6 @@ function strikingDistance(input, dependencies = defaultDependencies$5) {
|
|
|
30680
31531
|
return strikingDistanceWith(input, dependencies);
|
|
30681
31532
|
}
|
|
30682
31533
|
//#endregion
|
|
30683
|
-
//#region packages/core/dist/updates/search-updates.js
|
|
30684
|
-
function classifyUpdate(name, product) {
|
|
30685
|
-
const lower = `${name} ${product}`.toLowerCase();
|
|
30686
|
-
if (lower.includes("core")) return "core";
|
|
30687
|
-
if (lower.includes("spam")) return "spam";
|
|
30688
|
-
if (lower.includes("discover")) return "discover";
|
|
30689
|
-
if (lower.includes("ranking")) return "ranking";
|
|
30690
|
-
if (lower.includes("index")) return "indexing";
|
|
30691
|
-
if (lower.includes("serving")) return "serving";
|
|
30692
|
-
return "other";
|
|
30693
|
-
}
|
|
30694
|
-
function searchUpdatesFromIncidents(incidents, input = {}) {
|
|
30695
|
-
const product = input.product?.toLowerCase();
|
|
30696
|
-
return incidents.filter((incident) => !product || incident.service_name.toLowerCase() === product).map((incident) => {
|
|
30697
|
-
const status = incident.end ? "complete" : incident.status_impact === "SERVICE_INFORMATION" ? "rolling-out" : "incident";
|
|
30698
|
-
return {
|
|
30699
|
-
id: incident.id,
|
|
30700
|
-
name: incident.external_desc,
|
|
30701
|
-
type: classifyUpdate(incident.external_desc, incident.service_name),
|
|
30702
|
-
product: incident.service_name,
|
|
30703
|
-
start: incident.begin,
|
|
30704
|
-
end: incident.end,
|
|
30705
|
-
status,
|
|
30706
|
-
sourceUrl: `https://status.search.google.com/${incident.uri}`
|
|
30707
|
-
};
|
|
30708
|
-
}).slice(0, input.limit ?? 25);
|
|
30709
|
-
}
|
|
30710
|
-
function findOverlappingSearchUpdates(input) {
|
|
30711
|
-
const paddingMs = (input.paddingDays ?? 3) * 864e5;
|
|
30712
|
-
const windowStart = new Date(input.startDate).getTime() - paddingMs;
|
|
30713
|
-
const windowEnd = new Date(input.endDate).getTime() + paddingMs;
|
|
30714
|
-
return input.updates.filter((update) => {
|
|
30715
|
-
const updateStart = new Date(update.start).getTime();
|
|
30716
|
-
const updateEnd = update.end ? new Date(update.end).getTime() : Date.now();
|
|
30717
|
-
return updateStart <= windowEnd && updateEnd >= windowStart;
|
|
30718
|
-
});
|
|
30719
|
-
}
|
|
30720
|
-
//#endregion
|
|
30721
31534
|
//#region packages/core/dist/updates/search-status.js
|
|
30722
31535
|
const INCIDENTS_URL = "https://status.search.google.com/incidents.json";
|
|
30723
31536
|
async function fetchSearchStatusIncidents() {
|
|
@@ -31094,7 +31907,7 @@ async function trafficAnomalyWith(input, dependencies) {
|
|
|
31094
31907
|
const range = input.startDate && input.endDate ? {
|
|
31095
31908
|
startDate: input.startDate,
|
|
31096
31909
|
endDate: input.endDate
|
|
31097
|
-
} : defaultDateRange
|
|
31910
|
+
} : defaultDateRange(days, dependencies.now());
|
|
31098
31911
|
const result = await dependencies.searchAnalytics(input.site, {
|
|
31099
31912
|
...range,
|
|
31100
31913
|
dimensions: ["date"],
|
|
@@ -31186,7 +31999,7 @@ function fallbackRange(input) {
|
|
|
31186
31999
|
return input.startDate && input.endDate ? {
|
|
31187
32000
|
startDate: input.startDate,
|
|
31188
32001
|
endDate: input.endDate
|
|
31189
|
-
} : defaultDateRange
|
|
32002
|
+
} : defaultDateRange(input.days ?? 28);
|
|
31190
32003
|
}
|
|
31191
32004
|
function emptyAnomaly(input) {
|
|
31192
32005
|
return {
|
|
@@ -31391,7 +32204,7 @@ function emptyStriking(input) {
|
|
|
31391
32204
|
return {
|
|
31392
32205
|
site: input.site,
|
|
31393
32206
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
31394
|
-
range: defaultDateRange
|
|
32207
|
+
range: defaultDateRange(input.days ?? 28),
|
|
31395
32208
|
rangeDays: input.days ?? 28,
|
|
31396
32209
|
source: {
|
|
31397
32210
|
provider: "google-search-console",
|
|
@@ -33306,7 +34119,7 @@ function rowMetrics(row) {
|
|
|
33306
34119
|
}
|
|
33307
34120
|
async function linkRecover(input) {
|
|
33308
34121
|
const days = input.days ?? 90;
|
|
33309
|
-
const range = defaultDateRange
|
|
34122
|
+
const range = defaultDateRange(days);
|
|
33310
34123
|
const result = await querySearchAnalytics(input.site, {
|
|
33311
34124
|
...range,
|
|
33312
34125
|
dimensions: ["page"],
|
|
@@ -34467,7 +35280,7 @@ async function queryClusterReport(input) {
|
|
|
34467
35280
|
maximum: 548,
|
|
34468
35281
|
label: "days"
|
|
34469
35282
|
});
|
|
34470
|
-
const range = defaultDateRange
|
|
35283
|
+
const range = defaultDateRange(days);
|
|
34471
35284
|
const filters = input.scope ? [{
|
|
34472
35285
|
groupType: "and",
|
|
34473
35286
|
filters: [{
|
|
@@ -35649,7 +36462,7 @@ async function refreshPrioritiesWorkflow(input) {
|
|
|
35649
36462
|
verifyContent: input.verifyContent ?? true,
|
|
35650
36463
|
verifyLimit: input.verifyContent === false ? void 0 : input.verifyLimit ?? Math.min(input.limit ?? 5, 5)
|
|
35651
36464
|
});
|
|
35652
|
-
const range = defaultDateRange
|
|
36465
|
+
const range = defaultDateRange(input.days ?? 28);
|
|
35653
36466
|
const analytics = await fetchLandingPageValues({
|
|
35654
36467
|
connection: input.analyticsConnection,
|
|
35655
36468
|
propertyId: input.googleAnalyticsPropertyId,
|
|
@@ -35951,666 +36764,221 @@ function buildActions(input) {
|
|
|
35951
36764
|
});
|
|
35952
36765
|
if (queryWinners > 0 && queryLosers === 0) actions.push({
|
|
35953
36766
|
title: "Expand winning query angles",
|
|
35954
|
-
action: "The returned query split contains positive movers and no negative movers. Use that query wording to improve internal anchors and supporting copy on related pages.",
|
|
35955
|
-
confidence: "medium"
|
|
35956
|
-
});
|
|
35957
|
-
return actions;
|
|
35958
|
-
}
|
|
35959
|
-
function sameDirectionItems(items, direction) {
|
|
35960
|
-
return items.filter((item) => direction === "winner" ? item.clickDelta > 0 : item.clickDelta < 0);
|
|
35961
|
-
}
|
|
35962
|
-
function movementRows(input) {
|
|
35963
|
-
const grouped = /* @__PURE__ */ new Map();
|
|
35964
|
-
for (const item of input.items) {
|
|
35965
|
-
const signature = templateForUrl(item.key, input.clusters);
|
|
35966
|
-
if (signature === "/") continue;
|
|
35967
|
-
const cluster = input.clusters.find((item) => item.signature === signature);
|
|
35968
|
-
const existing = grouped.get(signature) ?? {
|
|
35969
|
-
cluster,
|
|
35970
|
-
signature,
|
|
35971
|
-
items: [],
|
|
35972
|
-
clickDelta: 0,
|
|
35973
|
-
impressionDelta: 0
|
|
35974
|
-
};
|
|
35975
|
-
existing.items.push(item);
|
|
35976
|
-
existing.clickDelta += item.clickDelta;
|
|
35977
|
-
existing.impressionDelta += item.impressionDelta;
|
|
35978
|
-
grouped.set(signature, existing);
|
|
35979
|
-
}
|
|
35980
|
-
return [...grouped.values()];
|
|
35981
|
-
}
|
|
35982
|
-
function clusterCommonTerms(cluster) {
|
|
35983
|
-
if (!cluster) return [];
|
|
35984
|
-
return [...new Set(cluster.shape.variableSegments.flatMap((segment) => segment.tokenExamples))].slice(0, 6);
|
|
35985
|
-
}
|
|
35986
|
-
function templateMovementForDirection(input) {
|
|
35987
|
-
return movementRows({
|
|
35988
|
-
items: sameDirectionItems(input.items, input.direction),
|
|
35989
|
-
clusters: input.clusters
|
|
35990
|
-
}).map((row) => {
|
|
35991
|
-
const movement = Math.abs(row.clickDelta);
|
|
35992
|
-
const movementShare = input.totalMovement ? movement / input.totalMovement : 0;
|
|
35993
|
-
if (row.items.length < 3 || movementShare < .2) return void 0;
|
|
35994
|
-
const confidence = row.items.length >= 10 && movementShare >= .35 && input.segmentCoverage.limitedRows === 0 && !input.segmentCoverage.sourcePossiblyTruncated ? "high" : "medium";
|
|
35995
|
-
const directionWord = input.direction === "winner" ? "gained" : "lost";
|
|
35996
|
-
const commonTerms = clusterCommonTerms(row.cluster);
|
|
35997
|
-
const termText = commonTerms.length ? ` Common URL terms: ${commonTerms.join(", ")}.` : "";
|
|
35998
|
-
return {
|
|
35999
|
-
signature: row.signature,
|
|
36000
|
-
direction: input.direction,
|
|
36001
|
-
confidence,
|
|
36002
|
-
urlCount: row.items.length,
|
|
36003
|
-
clickDelta: Number(row.clickDelta.toFixed(3)),
|
|
36004
|
-
impressionDelta: Number(row.impressionDelta.toFixed(3)),
|
|
36005
|
-
movementShare: Number(movementShare.toFixed(3)),
|
|
36006
|
-
movementShareScope: "returned-directional-page-click-movement",
|
|
36007
|
-
confidenceScope: "pattern-within-returned-matched-retained-page-segments",
|
|
36008
|
-
segmentCoverage: input.segmentCoverage,
|
|
36009
|
-
commonTerms,
|
|
36010
|
-
sampleUrls: row.items.slice(0, 5).map((item) => item.key),
|
|
36011
|
-
summary: `${row.signature} ${directionWord} ${formatMagnitude(row.clickDelta)} clicks across ${row.items.length} moved URLs (${formatPercent(movementShare)} of returned ${input.direction} page click movement). Coverage: ${input.segmentCoverage.returnedRows} of ${input.segmentCoverage.eligibleRows} eligible matched retained page rows returned under result limit ${input.segmentCoverage.resultLimit}.${termText}`
|
|
36012
|
-
};
|
|
36013
|
-
}).filter((item) => Boolean(item)).sort((a, b) => Math.abs(b.clickDelta) - Math.abs(a.clickDelta) || b.urlCount - a.urlCount || compareText(a.signature, b.signature));
|
|
36014
|
-
}
|
|
36015
|
-
function inferTemplateMovement(pageSegment) {
|
|
36016
|
-
const movedPages = [...pageSegment.winners, ...pageSegment.losers].filter((item) => item.key.startsWith("http")).filter((item) => Math.abs(item.clickDelta) > 0).sort((left, right) => Math.abs(right.clickDelta) - Math.abs(left.clickDelta) || compareText(left.key, right.key));
|
|
36017
|
-
if (movedPages.length < 6) return [];
|
|
36018
|
-
const clusters = clusterPseoTemplates(movedPages.map((item) => item.key), {
|
|
36019
|
-
minUrls: 3,
|
|
36020
|
-
minShare: .15,
|
|
36021
|
-
limit: 20
|
|
36022
|
-
}).filter((cluster) => cluster.signature !== "/" && cluster.urlCount >= 3);
|
|
36023
|
-
if (!clusters.length) return [];
|
|
36024
|
-
const winnerMovement = pageSegment.winners.reduce((sum, item) => sum + Math.max(0, item.clickDelta), 0);
|
|
36025
|
-
const loserMovement = Math.abs(pageSegment.losers.reduce((sum, item) => sum + Math.min(0, item.clickDelta), 0));
|
|
36026
|
-
return [...templateMovementForDirection({
|
|
36027
|
-
direction: "winner",
|
|
36028
|
-
items: movedPages,
|
|
36029
|
-
clusters,
|
|
36030
|
-
totalMovement: winnerMovement,
|
|
36031
|
-
segmentCoverage: pageSegment.coverage
|
|
36032
|
-
}), ...templateMovementForDirection({
|
|
36033
|
-
direction: "loser",
|
|
36034
|
-
items: movedPages,
|
|
36035
|
-
clusters,
|
|
36036
|
-
totalMovement: loserMovement,
|
|
36037
|
-
segmentCoverage: pageSegment.coverage
|
|
36038
|
-
})].slice(0, 6);
|
|
36039
|
-
}
|
|
36040
|
-
async function updatePostmortemWorkflow(input, dependencies = {}) {
|
|
36041
|
-
const limit = input.limit ?? 20;
|
|
36042
|
-
const runUpdateCorrelation = dependencies.updateCorrelation ?? updateCorrelation;
|
|
36043
|
-
const runSegmentImpact = dependencies.segmentImpact ?? segmentImpact;
|
|
36044
|
-
const [update, page, query, device, country] = await Promise.all([
|
|
36045
|
-
runUpdateCorrelation(input),
|
|
36046
|
-
runSegmentImpact({
|
|
36047
|
-
...input,
|
|
36048
|
-
dimension: "page",
|
|
36049
|
-
limit
|
|
36050
|
-
}),
|
|
36051
|
-
runSegmentImpact({
|
|
36052
|
-
...input,
|
|
36053
|
-
dimension: "query",
|
|
36054
|
-
limit
|
|
36055
|
-
}),
|
|
36056
|
-
runSegmentImpact({
|
|
36057
|
-
...input,
|
|
36058
|
-
dimension: "device",
|
|
36059
|
-
limit
|
|
36060
|
-
}),
|
|
36061
|
-
runSegmentImpact({
|
|
36062
|
-
...input,
|
|
36063
|
-
dimension: "country",
|
|
36064
|
-
limit
|
|
36065
|
-
})
|
|
36066
|
-
]);
|
|
36067
|
-
const segments = {
|
|
36068
|
-
page: splitSegments(page),
|
|
36069
|
-
query: splitSegments(query),
|
|
36070
|
-
device: splitSegments(device),
|
|
36071
|
-
country: splitSegments(country)
|
|
36072
|
-
};
|
|
36073
|
-
const insights = [
|
|
36074
|
-
insightForSegment("page", segments.page),
|
|
36075
|
-
insightForSegment("query", segments.query),
|
|
36076
|
-
insightForSegment("device", segments.device),
|
|
36077
|
-
insightForSegment("country", segments.country)
|
|
36078
|
-
];
|
|
36079
|
-
const templateMovement = inferTemplateMovement(segments.page);
|
|
36080
|
-
const segmentStepStatus = [
|
|
36081
|
-
segments.page,
|
|
36082
|
-
segments.query,
|
|
36083
|
-
segments.device,
|
|
36084
|
-
segments.country
|
|
36085
|
-
].every((segment) => segment.dataStatus === "empty" || segment.dataStatus === "unavailable") ? "skipped" : "completed";
|
|
36086
|
-
return workflowReport({
|
|
36087
|
-
workflow: "update-postmortem",
|
|
36088
|
-
site: input.site,
|
|
36089
|
-
summary: buildSummary({
|
|
36090
|
-
update,
|
|
36091
|
-
insights
|
|
36092
|
-
}),
|
|
36093
|
-
steps: [{
|
|
36094
|
-
tool: "seo_update_correlate",
|
|
36095
|
-
status: "completed",
|
|
36096
|
-
summary: `Cause not established. ${update.summary}`
|
|
36097
|
-
}, {
|
|
36098
|
-
tool: "seo_segment_impact",
|
|
36099
|
-
status: segmentStepStatus,
|
|
36100
|
-
summary: insights.map((insight) => insight.summary).join(" ")
|
|
36101
|
-
}],
|
|
36102
|
-
actions: buildActions({
|
|
36103
|
-
update,
|
|
36104
|
-
segments,
|
|
36105
|
-
templateMovement
|
|
36106
|
-
}),
|
|
36107
|
-
output: {
|
|
36108
|
-
update,
|
|
36109
|
-
insights,
|
|
36110
|
-
templateMovement,
|
|
36111
|
-
segments
|
|
36112
|
-
}
|
|
36113
|
-
});
|
|
36114
|
-
}
|
|
36115
|
-
//#endregion
|
|
36116
|
-
//#region packages/core/dist/bing/api.js
|
|
36117
|
-
const BASE = "https://ssl.bing.com/webmaster/api.svc/json";
|
|
36118
|
-
const MAX_BYTES$1 = 8e6;
|
|
36119
|
-
function bingCall(auth, options) {
|
|
36120
|
-
return async (method, params = {}) => {
|
|
36121
|
-
const url = new URL(`${BASE}/${method}`);
|
|
36122
|
-
for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);
|
|
36123
|
-
const headers = { accept: "application/json" };
|
|
36124
|
-
if ("apiKey" in auth) url.searchParams.set("apikey", auth.apiKey);
|
|
36125
|
-
else headers.authorization = `Bearer ${await auth.accessToken()}`;
|
|
36126
|
-
let response;
|
|
36127
|
-
try {
|
|
36128
|
-
response = await fetch(url, {
|
|
36129
|
-
headers,
|
|
36130
|
-
signal: AbortSignal.timeout(3e4)
|
|
36131
|
-
});
|
|
36132
|
-
} catch (error) {
|
|
36133
|
-
throw new SeoError("OPTIONAL_PROVIDER_UNAVAILABLE", `Bing Webmaster did not respond: ${error instanceof Error ? error.message : String(error)}`);
|
|
36134
|
-
}
|
|
36135
|
-
if (!response.ok) {
|
|
36136
|
-
const detail = (await response.text().catch(() => "")).slice(0, 200);
|
|
36137
|
-
if (response.status === 401 || /invalidapikey/i.test(detail)) throw new SeoError("AUTH_REQUIRED", `Bing Webmaster rejected the sign-in. ${options.signIn}`);
|
|
36138
|
-
if (response.status === 403 || /notauthorized/i.test(detail)) throw new SeoError("ACCESS_DENIED", "Bing Webmaster denied access to this site. It must be verified in the connected Bing account.");
|
|
36139
|
-
if (/ThrottleIP/i.test(detail)) throw new SeoError("RATE_LIMITED", "Bing Webmaster is blocking requests from this network (ThrottleIP). Run the same report with the CLI on your own machine, where Bing answers.");
|
|
36140
|
-
if (response.status === 429 || /throttle/i.test(detail)) throw new SeoError("RATE_LIMITED", "Bing Webmaster is limiting requests. Try again in a few minutes.");
|
|
36141
|
-
throw new SeoError("OPTIONAL_PROVIDER_UNAVAILABLE", `Bing Webmaster answered ${response.status}. ${detail}`.trim());
|
|
36142
|
-
}
|
|
36143
|
-
return (await readJson(response, "Bing Webmaster", { maxBytes: MAX_BYTES$1 })).d;
|
|
36144
|
-
};
|
|
36145
|
-
}
|
|
36146
|
-
function day(value) {
|
|
36147
|
-
if (typeof value !== "string") return null;
|
|
36148
|
-
const legacy = /^\/Date\((-?\d+)(?:[+-]\d{4})?\)\/$/.exec(value);
|
|
36149
|
-
const date = legacy ? new Date(Number(legacy[1])) : new Date(value);
|
|
36150
|
-
return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10);
|
|
36151
|
-
}
|
|
36152
|
-
const count = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
|
36153
|
-
function list(data, label) {
|
|
36154
|
-
if (!Array.isArray(data)) throw new SeoError("OPTIONAL_PROVIDER_UNAVAILABLE", `Bing Webmaster returned an invalid ${label}.`);
|
|
36155
|
-
return data.filter((item) => Boolean(item) && typeof item === "object");
|
|
36156
|
-
}
|
|
36157
|
-
async function listBingSites(call) {
|
|
36158
|
-
return list(await call("GetUserSites"), "site list").filter((row) => typeof row.Url === "string").map((row) => ({
|
|
36159
|
-
url: row.Url,
|
|
36160
|
-
verified: row.IsVerified === true
|
|
36161
|
-
})).sort((a, b) => a.url.localeCompare(b.url, "en"));
|
|
36162
|
-
}
|
|
36163
|
-
async function bingTraffic(call, site) {
|
|
36164
|
-
const rows = [];
|
|
36165
|
-
let invalidRows = 0;
|
|
36166
|
-
for (const row of list(await call("GetRankAndTrafficStats", { siteUrl: site }), "traffic list")) {
|
|
36167
|
-
const date = day(row.Date);
|
|
36168
|
-
const clicks = count(row.Clicks);
|
|
36169
|
-
const impressions = count(row.Impressions);
|
|
36170
|
-
if (!date || clicks === null || impressions === null) {
|
|
36171
|
-
invalidRows += 1;
|
|
36172
|
-
continue;
|
|
36173
|
-
}
|
|
36174
|
-
rows.push({
|
|
36175
|
-
date,
|
|
36176
|
-
clicks,
|
|
36177
|
-
impressions
|
|
36178
|
-
});
|
|
36179
|
-
}
|
|
36180
|
-
rows.sort((a, b) => a.date.localeCompare(b.date, "en"));
|
|
36181
|
-
return {
|
|
36182
|
-
rows,
|
|
36183
|
-
invalidRows
|
|
36184
|
-
};
|
|
36185
|
-
}
|
|
36186
|
-
async function keyed(call, method, params) {
|
|
36187
|
-
const rows = [];
|
|
36188
|
-
let invalidRows = 0;
|
|
36189
|
-
for (const row of list(await call(method, params), "row list")) {
|
|
36190
|
-
const date = day(row.Date);
|
|
36191
|
-
const key = typeof row.Query === "string" ? row.Query.trim() : "";
|
|
36192
|
-
const clicks = count(row.Clicks);
|
|
36193
|
-
const impressions = count(row.Impressions);
|
|
36194
|
-
const position = count(row.AvgImpressionPosition);
|
|
36195
|
-
if (!date || !key || clicks === null || impressions === null) {
|
|
36196
|
-
invalidRows += 1;
|
|
36197
|
-
continue;
|
|
36198
|
-
}
|
|
36199
|
-
rows.push({
|
|
36200
|
-
date,
|
|
36201
|
-
key,
|
|
36202
|
-
clicks,
|
|
36203
|
-
impressions,
|
|
36204
|
-
position
|
|
36205
|
-
});
|
|
36206
|
-
}
|
|
36207
|
-
rows.sort((a, b) => a.date.localeCompare(b.date, "en") || a.key.localeCompare(b.key, "en"));
|
|
36208
|
-
return {
|
|
36209
|
-
rows,
|
|
36210
|
-
invalidRows
|
|
36211
|
-
};
|
|
36212
|
-
}
|
|
36213
|
-
const bingQueries = (call, site) => keyed(call, "GetQueryStats", { siteUrl: site });
|
|
36214
|
-
const bingPages = (call, site) => keyed(call, "GetPageStats", { siteUrl: site });
|
|
36215
|
-
function bingSiteFor(sites, target) {
|
|
36216
|
-
const host = (value) => {
|
|
36217
|
-
try {
|
|
36218
|
-
return new URL(value.includes("://") ? value : `https://${value.replace(/^sc-domain:/, "")}`).hostname.replace(/^www\./, "");
|
|
36219
|
-
} catch {
|
|
36220
|
-
return null;
|
|
36221
|
-
}
|
|
36222
|
-
};
|
|
36223
|
-
const wanted = host(target);
|
|
36224
|
-
if (!wanted) return null;
|
|
36225
|
-
return sites.filter((site) => host(site.url) === wanted).sort((a, b) => Number(b.verified) - Number(a.verified) || Number(b.url.startsWith("https://")) - Number(a.url.startsWith("https://")) || a.url.localeCompare(b.url, "en"))[0] ?? null;
|
|
36226
|
-
}
|
|
36227
|
-
//#endregion
|
|
36228
|
-
//#region packages/core/dist/commands/present.js
|
|
36229
|
-
const LIST_KEYS = [
|
|
36230
|
-
"items",
|
|
36231
|
-
"rows",
|
|
36232
|
-
"results",
|
|
36233
|
-
"pages",
|
|
36234
|
-
"urls",
|
|
36235
|
-
"queries",
|
|
36236
|
-
"keywords",
|
|
36237
|
-
"opportunities",
|
|
36238
|
-
"candidates",
|
|
36239
|
-
"clusters",
|
|
36240
|
-
"issues",
|
|
36241
|
-
"domains",
|
|
36242
|
-
"competitors",
|
|
36243
|
-
"links"
|
|
36244
|
-
];
|
|
36245
|
-
const SKIP_KEYS = /* @__PURE__ */ new Set([
|
|
36246
|
-
"caveats",
|
|
36247
|
-
"warnings",
|
|
36248
|
-
"recommendations",
|
|
36249
|
-
"findings",
|
|
36250
|
-
"sampleUrls",
|
|
36251
|
-
"sampleQueries",
|
|
36252
|
-
"omissions",
|
|
36253
|
-
"dimensions",
|
|
36254
|
-
"brand",
|
|
36255
|
-
"pageChecks"
|
|
36256
|
-
]);
|
|
36257
|
-
const ID_COLUMNS = [
|
|
36258
|
-
"metric",
|
|
36259
|
-
"direction",
|
|
36260
|
-
"changeDate",
|
|
36261
|
-
"query",
|
|
36262
|
-
"queryFlags",
|
|
36263
|
-
"keyword",
|
|
36264
|
-
"url",
|
|
36265
|
-
"page",
|
|
36266
|
-
"finalUrl",
|
|
36267
|
-
"title",
|
|
36268
|
-
"domain",
|
|
36269
|
-
"target",
|
|
36270
|
-
"check",
|
|
36271
|
-
"rule",
|
|
36272
|
-
"severity",
|
|
36273
|
-
"status",
|
|
36274
|
-
"urls",
|
|
36275
|
-
"topPage"
|
|
36276
|
-
];
|
|
36277
|
-
const METRIC_COLUMNS = [
|
|
36278
|
-
"percentChange",
|
|
36279
|
-
"baselineMean",
|
|
36280
|
-
"comparisonMean",
|
|
36281
|
-
"clicks",
|
|
36282
|
-
"impressions",
|
|
36283
|
-
"ctr",
|
|
36284
|
-
"position",
|
|
36285
|
-
"previousClicks",
|
|
36286
|
-
"clicksChange",
|
|
36287
|
-
"searchVolume",
|
|
36288
|
-
"volume",
|
|
36289
|
-
"cpc",
|
|
36290
|
-
"difficulty",
|
|
36291
|
-
"rank",
|
|
36292
|
-
"count",
|
|
36293
|
-
"affectedUrls",
|
|
36294
|
-
"score",
|
|
36295
|
-
"priority.score"
|
|
36296
|
-
];
|
|
36297
|
-
const SUMMARY_ROWS = 10;
|
|
36298
|
-
const CELL_MAX = 80;
|
|
36299
|
-
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
36300
|
-
const isPrimitive = (value) => value === null || [
|
|
36301
|
-
"string",
|
|
36302
|
-
"number",
|
|
36303
|
-
"boolean"
|
|
36304
|
-
].includes(typeof value);
|
|
36305
|
-
function mainList(report) {
|
|
36306
|
-
const found = [];
|
|
36307
|
-
const visit = (value, path, depth) => {
|
|
36308
|
-
if (!isObject(value) || depth > 2) return;
|
|
36309
|
-
for (const [key, child] of Object.entries(value)) {
|
|
36310
|
-
if (SKIP_KEYS.has(key)) continue;
|
|
36311
|
-
const childPath = path ? `${path}.${key}` : key;
|
|
36312
|
-
if (Array.isArray(child) && child.length > 0 && child.every(isObject)) found.push({
|
|
36313
|
-
path: childPath,
|
|
36314
|
-
rows: child,
|
|
36315
|
-
known: LIST_KEYS.includes(key)
|
|
36316
|
-
});
|
|
36317
|
-
else if (isObject(child)) visit(child, childPath, depth + 1);
|
|
36318
|
-
}
|
|
36319
|
-
};
|
|
36320
|
-
visit(report, "", 0);
|
|
36321
|
-
found.sort((a, b) => Number(b.known) - Number(a.known) || b.rows.length - a.rows.length || a.path.length - b.path.length);
|
|
36322
|
-
return found[0] ?? null;
|
|
36323
|
-
}
|
|
36324
|
-
function flatten(row) {
|
|
36325
|
-
const flat = {};
|
|
36326
|
-
for (const [key, value] of Object.entries(row)) if (isPrimitive(value)) flat[key] = value;
|
|
36327
|
-
else if (Array.isArray(value) && value.length && value.every(isPrimitive)) flat[key] = value.join(" ");
|
|
36328
|
-
else if (isObject(value)) {
|
|
36329
|
-
for (const [inner, innerValue] of Object.entries(value)) if (isPrimitive(innerValue)) flat[`${key}.${inner}`] = innerValue;
|
|
36330
|
-
}
|
|
36331
|
-
return flat;
|
|
36332
|
-
}
|
|
36333
|
-
function columnsFor(rows, max) {
|
|
36334
|
-
const seen = /* @__PURE__ */ new Map();
|
|
36335
|
-
for (const row of rows.slice(0, 50)) for (const [key, value] of Object.entries(row)) {
|
|
36336
|
-
const entry = seen.get(key) ?? {
|
|
36337
|
-
count: 0,
|
|
36338
|
-
length: 0
|
|
36339
|
-
};
|
|
36340
|
-
entry.count++;
|
|
36341
|
-
entry.length += String(value ?? "").length;
|
|
36342
|
-
seen.set(key, entry);
|
|
36343
|
-
}
|
|
36344
|
-
const usable = [...seen].filter(([key, { count, length }]) => {
|
|
36345
|
-
const average = length / count;
|
|
36346
|
-
return ID_COLUMNS.includes(key) || average <= 60;
|
|
36347
|
-
});
|
|
36348
|
-
const rank = (key) => {
|
|
36349
|
-
const id = ID_COLUMNS.indexOf(key);
|
|
36350
|
-
if (id >= 0) return id;
|
|
36351
|
-
const metric = METRIC_COLUMNS.indexOf(key);
|
|
36352
|
-
if (metric >= 0) return 100 + metric;
|
|
36353
|
-
return 1e3;
|
|
36354
|
-
};
|
|
36355
|
-
return usable.map(([key]) => key).filter((key) => !/(^|\.)(id|method|heuristic|estimatedClickLift|confidence|label)$|^site$/.test(key)).sort((a, b) => rank(a) - rank(b)).slice(0, max);
|
|
36356
|
-
}
|
|
36357
|
-
function cell(value, origin, max = CELL_MAX) {
|
|
36358
|
-
if (value === null || value === void 0) return "";
|
|
36359
|
-
let text = String(value);
|
|
36360
|
-
if (origin && text.startsWith(origin)) text = text.slice(origin.length) || "/";
|
|
36361
|
-
if (typeof value === "number" && !Number.isInteger(value)) {
|
|
36362
|
-
const places = Math.abs(value) < 1 ? 1e3 : 100;
|
|
36363
|
-
text = String(Math.round(value * places) / places);
|
|
36364
|
-
}
|
|
36365
|
-
text = text.replaceAll("|", "\\|").replaceAll("\n", " ");
|
|
36366
|
-
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
36767
|
+
action: "The returned query split contains positive movers and no negative movers. Use that query wording to improve internal anchors and supporting copy on related pages.",
|
|
36768
|
+
confidence: "medium"
|
|
36769
|
+
});
|
|
36770
|
+
return actions;
|
|
36367
36771
|
}
|
|
36368
|
-
function
|
|
36369
|
-
|
|
36370
|
-
return [columns.join(" | "), ...rows.map((row) => columns.map((key) => cell(row[key], origin, max)).join(" | "))].join("\n");
|
|
36772
|
+
function sameDirectionItems(items, direction) {
|
|
36773
|
+
return items.filter((item) => direction === "winner" ? item.clickDelta > 0 : item.clickDelta < 0);
|
|
36371
36774
|
}
|
|
36372
|
-
function
|
|
36373
|
-
|
|
36374
|
-
|
|
36375
|
-
|
|
36775
|
+
function movementRows(input) {
|
|
36776
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
36777
|
+
for (const item of input.items) {
|
|
36778
|
+
const signature = templateForUrl(item.key, input.clusters);
|
|
36779
|
+
if (signature === "/") continue;
|
|
36780
|
+
const cluster = input.clusters.find((item) => item.signature === signature);
|
|
36781
|
+
const existing = grouped.get(signature) ?? {
|
|
36782
|
+
cluster,
|
|
36783
|
+
signature,
|
|
36784
|
+
items: [],
|
|
36785
|
+
clickDelta: 0,
|
|
36786
|
+
impressionDelta: 0
|
|
36787
|
+
};
|
|
36788
|
+
existing.items.push(item);
|
|
36789
|
+
existing.clickDelta += item.clickDelta;
|
|
36790
|
+
existing.impressionDelta += item.impressionDelta;
|
|
36791
|
+
grouped.set(signature, existing);
|
|
36376
36792
|
}
|
|
36377
|
-
return
|
|
36378
|
-
}
|
|
36379
|
-
function dateRange(report) {
|
|
36380
|
-
const range = isObject(report.range) ? report.range : report;
|
|
36381
|
-
const from = findString(range, [
|
|
36382
|
-
"startDate",
|
|
36383
|
-
"from",
|
|
36384
|
-
"start"
|
|
36385
|
-
]);
|
|
36386
|
-
const to = findString(range, [
|
|
36387
|
-
"endDate",
|
|
36388
|
-
"to",
|
|
36389
|
-
"end"
|
|
36390
|
-
]);
|
|
36391
|
-
return from && to ? `${from} to ${to}` : null;
|
|
36392
|
-
}
|
|
36393
|
-
function limitsOf(report) {
|
|
36394
|
-
return [report.caveats, isObject(report.retainedEvidence) ? report.retainedEvidence.caveats : null].flatMap((list) => Array.isArray(list) ? list : []).filter((line) => typeof line === "string").filter((line) => !/^(Date window|Selection):/.test(line)).slice(0, 4).map((line) => line.length > 200 ? `${line.slice(0, 199)}…` : line);
|
|
36395
|
-
}
|
|
36396
|
-
function keyFacts(report) {
|
|
36397
|
-
if (!isObject(report.summary)) return null;
|
|
36398
|
-
const facts = Object.entries(report.summary).filter(([key, value]) => key !== "verdict" && isPrimitive(value) && value !== null).slice(0, 8).map(([key, value]) => `${key} ${cell(value, null)}`);
|
|
36399
|
-
return facts.length ? facts.join(", ") : null;
|
|
36400
|
-
}
|
|
36401
|
-
function filteredLine(report) {
|
|
36402
|
-
if (!isObject(report.selection)) return null;
|
|
36403
|
-
const counts = Object.entries(report.selection).filter(([key, value]) => typeof value === "number" && value > 0 && !/(source|aggregated|valid|eligible|returned|total|groups|days)/i.test(key)).map(([key, value]) => `${key} ${value}`);
|
|
36404
|
-
return counts.length ? `Filtered out: ${counts.join(", ")}.` : null;
|
|
36793
|
+
return [...grouped.values()];
|
|
36405
36794
|
}
|
|
36406
|
-
function
|
|
36407
|
-
|
|
36408
|
-
|
|
36409
|
-
const out = {};
|
|
36410
|
-
for (const [key, value] of Object.entries(report)) if (!last.includes(key)) out[key] = value;
|
|
36411
|
-
for (const key of last) out[key] = report[key];
|
|
36412
|
-
return out;
|
|
36795
|
+
function clusterCommonTerms(cluster) {
|
|
36796
|
+
if (!cluster) return [];
|
|
36797
|
+
return [...new Set(cluster.shape.variableSegments.flatMap((segment) => segment.tokenExamples))].slice(0, 6);
|
|
36413
36798
|
}
|
|
36414
|
-
function
|
|
36415
|
-
|
|
36416
|
-
|
|
36417
|
-
|
|
36418
|
-
|
|
36419
|
-
|
|
36420
|
-
|
|
36421
|
-
|
|
36422
|
-
|
|
36423
|
-
|
|
36424
|
-
|
|
36425
|
-
};
|
|
36426
|
-
}
|
|
36427
|
-
const list = mainList(report);
|
|
36428
|
-
const flat = list ? list.rows.map(flatten) : [];
|
|
36429
|
-
const head = [
|
|
36430
|
-
input.label,
|
|
36431
|
-
input.range ?? dateRange(report),
|
|
36432
|
-
findString(report, ["dataStatus"])
|
|
36433
|
-
].filter(Boolean).join(" · ");
|
|
36434
|
-
const facts = keyFacts(report);
|
|
36435
|
-
const limits = limitsOf(report);
|
|
36436
|
-
if (input.detail === "rows") {
|
|
36437
|
-
const page = flat.slice(input.offset, input.offset + input.limit);
|
|
36438
|
-
const columns = columnsFor(flat, 14);
|
|
36439
|
-
const end = input.offset + page.length;
|
|
36799
|
+
function templateMovementForDirection(input) {
|
|
36800
|
+
return movementRows({
|
|
36801
|
+
items: sameDirectionItems(input.items, input.direction),
|
|
36802
|
+
clusters: input.clusters
|
|
36803
|
+
}).map((row) => {
|
|
36804
|
+
const movement = Math.abs(row.clickDelta);
|
|
36805
|
+
const movementShare = input.totalMovement ? movement / input.totalMovement : 0;
|
|
36806
|
+
if (row.items.length < 3 || movementShare < .2) return void 0;
|
|
36807
|
+
const confidence = row.items.length >= 10 && movementShare >= .35 && input.segmentCoverage.limitedRows === 0 && !input.segmentCoverage.sourcePossiblyTruncated ? "high" : "medium";
|
|
36808
|
+
const directionWord = input.direction === "winner" ? "gained" : "lost";
|
|
36809
|
+
const commonTerms = clusterCommonTerms(row.cluster);
|
|
36810
|
+
const termText = commonTerms.length ? ` Common URL terms: ${commonTerms.join(", ")}.` : "";
|
|
36440
36811
|
return {
|
|
36441
|
-
|
|
36442
|
-
|
|
36443
|
-
|
|
36444
|
-
|
|
36445
|
-
|
|
36446
|
-
|
|
36447
|
-
|
|
36448
|
-
|
|
36449
|
-
|
|
36450
|
-
|
|
36451
|
-
|
|
36452
|
-
|
|
36453
|
-
}
|
|
36812
|
+
signature: row.signature,
|
|
36813
|
+
direction: input.direction,
|
|
36814
|
+
confidence,
|
|
36815
|
+
urlCount: row.items.length,
|
|
36816
|
+
clickDelta: Number(row.clickDelta.toFixed(3)),
|
|
36817
|
+
impressionDelta: Number(row.impressionDelta.toFixed(3)),
|
|
36818
|
+
movementShare: Number(movementShare.toFixed(3)),
|
|
36819
|
+
movementShareScope: "returned-directional-page-click-movement",
|
|
36820
|
+
confidenceScope: "pattern-within-returned-matched-retained-page-segments",
|
|
36821
|
+
segmentCoverage: input.segmentCoverage,
|
|
36822
|
+
commonTerms,
|
|
36823
|
+
sampleUrls: row.items.slice(0, 5).map((item) => item.key),
|
|
36824
|
+
summary: `${row.signature} ${directionWord} ${formatMagnitude(row.clickDelta)} clicks across ${row.items.length} moved URLs (${formatPercent(movementShare)} of returned ${input.direction} page click movement). Coverage: ${input.segmentCoverage.returnedRows} of ${input.segmentCoverage.eligibleRows} eligible matched retained page rows returned under result limit ${input.segmentCoverage.resultLimit}.${termText}`
|
|
36454
36825
|
};
|
|
36455
|
-
}
|
|
36456
|
-
const shown = flat.slice(0, Math.min(input.limit, SUMMARY_ROWS));
|
|
36457
|
-
const columns = columnsFor(flat, 8);
|
|
36458
|
-
return {
|
|
36459
|
-
text: [
|
|
36460
|
-
head,
|
|
36461
|
-
headline,
|
|
36462
|
-
facts ? `Facts: ${facts}.` : "",
|
|
36463
|
-
...input.notes ?? [],
|
|
36464
|
-
flat.length < 5 ? filteredLine(report) : null,
|
|
36465
|
-
list ? `${list.path} (${shown.length} of ${flat.length} shown):\n${table(shown, columns, origin)}` : "",
|
|
36466
|
-
limits.length ? `Limits:\n${limits.map((line) => `- ${line}`).join("\n")}` : "",
|
|
36467
|
-
`More: ${shown.length < flat.length ? `${input.more("rows", shown.length)} for more rows, or ` : ""}${input.more("full")} for every field.`
|
|
36468
|
-
].filter(Boolean).join("\n\n"),
|
|
36469
|
-
data: {
|
|
36470
|
-
summary: isObject(report.summary) ? report.summary : null,
|
|
36471
|
-
path: list?.path ?? null,
|
|
36472
|
-
total: flat.length,
|
|
36473
|
-
columns,
|
|
36474
|
-
rows: shown,
|
|
36475
|
-
limits
|
|
36476
|
-
}
|
|
36477
|
-
};
|
|
36826
|
+
}).filter((item) => Boolean(item)).sort((a, b) => Math.abs(b.clickDelta) - Math.abs(a.clickDelta) || b.urlCount - a.urlCount || compareText(a.signature, b.signature));
|
|
36478
36827
|
}
|
|
36479
|
-
function
|
|
36480
|
-
const
|
|
36481
|
-
if (
|
|
36482
|
-
|
|
36483
|
-
|
|
36484
|
-
|
|
36485
|
-
|
|
36486
|
-
|
|
36487
|
-
|
|
36488
|
-
|
|
36828
|
+
function inferTemplateMovement(pageSegment) {
|
|
36829
|
+
const movedPages = [...pageSegment.winners, ...pageSegment.losers].filter((item) => item.key.startsWith("http")).filter((item) => Math.abs(item.clickDelta) > 0).sort((left, right) => Math.abs(right.clickDelta) - Math.abs(left.clickDelta) || compareText(left.key, right.key));
|
|
36830
|
+
if (movedPages.length < 6) return [];
|
|
36831
|
+
const clusters = clusterPseoTemplates(movedPages.map((item) => item.key), {
|
|
36832
|
+
minUrls: 3,
|
|
36833
|
+
minShare: .15,
|
|
36834
|
+
limit: 20
|
|
36835
|
+
}).filter((cluster) => cluster.signature !== "/" && cluster.urlCount >= 3);
|
|
36836
|
+
if (!clusters.length) return [];
|
|
36837
|
+
const winnerMovement = pageSegment.winners.reduce((sum, item) => sum + Math.max(0, item.clickDelta), 0);
|
|
36838
|
+
const loserMovement = Math.abs(pageSegment.losers.reduce((sum, item) => sum + Math.min(0, item.clickDelta), 0));
|
|
36839
|
+
return [...templateMovementForDirection({
|
|
36840
|
+
direction: "winner",
|
|
36841
|
+
items: movedPages,
|
|
36842
|
+
clusters,
|
|
36843
|
+
totalMovement: winnerMovement,
|
|
36844
|
+
segmentCoverage: pageSegment.coverage
|
|
36845
|
+
}), ...templateMovementForDirection({
|
|
36846
|
+
direction: "loser",
|
|
36847
|
+
items: movedPages,
|
|
36848
|
+
clusters,
|
|
36849
|
+
totalMovement: loserMovement,
|
|
36850
|
+
segmentCoverage: pageSegment.coverage
|
|
36851
|
+
})].slice(0, 6);
|
|
36852
|
+
}
|
|
36853
|
+
async function updatePostmortemWorkflow(input, dependencies = {}) {
|
|
36854
|
+
const limit = input.limit ?? 20;
|
|
36855
|
+
const runUpdateCorrelation = dependencies.updateCorrelation ?? updateCorrelation;
|
|
36856
|
+
const runSegmentImpact = dependencies.segmentImpact ?? segmentImpact;
|
|
36857
|
+
const [update, page, query, device, country] = await Promise.all([
|
|
36858
|
+
runUpdateCorrelation(input),
|
|
36859
|
+
runSegmentImpact({
|
|
36860
|
+
...input,
|
|
36861
|
+
dimension: "page",
|
|
36862
|
+
limit
|
|
36863
|
+
}),
|
|
36864
|
+
runSegmentImpact({
|
|
36865
|
+
...input,
|
|
36866
|
+
dimension: "query",
|
|
36867
|
+
limit
|
|
36868
|
+
}),
|
|
36869
|
+
runSegmentImpact({
|
|
36870
|
+
...input,
|
|
36871
|
+
dimension: "device",
|
|
36872
|
+
limit
|
|
36873
|
+
}),
|
|
36874
|
+
runSegmentImpact({
|
|
36875
|
+
...input,
|
|
36876
|
+
dimension: "country",
|
|
36877
|
+
limit
|
|
36878
|
+
})
|
|
36879
|
+
]);
|
|
36880
|
+
const segments = {
|
|
36881
|
+
page: splitSegments(page),
|
|
36882
|
+
query: splitSegments(query),
|
|
36883
|
+
device: splitSegments(device),
|
|
36884
|
+
country: splitSegments(country)
|
|
36489
36885
|
};
|
|
36886
|
+
const insights = [
|
|
36887
|
+
insightForSegment("page", segments.page),
|
|
36888
|
+
insightForSegment("query", segments.query),
|
|
36889
|
+
insightForSegment("device", segments.device),
|
|
36890
|
+
insightForSegment("country", segments.country)
|
|
36891
|
+
];
|
|
36892
|
+
const templateMovement = inferTemplateMovement(segments.page);
|
|
36893
|
+
const segmentStepStatus = [
|
|
36894
|
+
segments.page,
|
|
36895
|
+
segments.query,
|
|
36896
|
+
segments.device,
|
|
36897
|
+
segments.country
|
|
36898
|
+
].every((segment) => segment.dataStatus === "empty" || segment.dataStatus === "unavailable") ? "skipped" : "completed";
|
|
36899
|
+
return workflowReport({
|
|
36900
|
+
workflow: "update-postmortem",
|
|
36901
|
+
site: input.site,
|
|
36902
|
+
summary: buildSummary({
|
|
36903
|
+
update,
|
|
36904
|
+
insights
|
|
36905
|
+
}),
|
|
36906
|
+
steps: [{
|
|
36907
|
+
tool: "seo_update_correlate",
|
|
36908
|
+
status: "completed",
|
|
36909
|
+
summary: `Cause not established. ${update.summary}`
|
|
36910
|
+
}, {
|
|
36911
|
+
tool: "seo_segment_impact",
|
|
36912
|
+
status: segmentStepStatus,
|
|
36913
|
+
summary: insights.map((insight) => insight.summary).join(" ")
|
|
36914
|
+
}],
|
|
36915
|
+
actions: buildActions({
|
|
36916
|
+
update,
|
|
36917
|
+
segments,
|
|
36918
|
+
templateMovement
|
|
36919
|
+
}),
|
|
36920
|
+
output: {
|
|
36921
|
+
update,
|
|
36922
|
+
insights,
|
|
36923
|
+
templateMovement,
|
|
36924
|
+
segments
|
|
36925
|
+
}
|
|
36926
|
+
});
|
|
36490
36927
|
}
|
|
36491
36928
|
//#endregion
|
|
36492
|
-
//#region packages/core/dist/
|
|
36493
|
-
|
|
36929
|
+
//#region packages/core/dist/clicky/api.js
|
|
36930
|
+
const ENDPOINT$1 = "https://api.clicky.com/api/stats/4";
|
|
36931
|
+
const MAX_BYTES$1 = 5e6;
|
|
36932
|
+
var ClickyError = class extends Error {
|
|
36494
36933
|
code;
|
|
36495
36934
|
constructor(code, message) {
|
|
36496
36935
|
super(message);
|
|
36497
36936
|
this.code = code;
|
|
36498
36937
|
}
|
|
36499
36938
|
};
|
|
36500
|
-
|
|
36501
|
-
|
|
36502
|
-
|
|
36503
|
-
|
|
36504
|
-
|
|
36505
|
-
|
|
36506
|
-
|
|
36507
|
-
|
|
36508
|
-
|
|
36509
|
-
|
|
36510
|
-
|
|
36511
|
-
|
|
36512
|
-
|
|
36513
|
-
|
|
36514
|
-
|
|
36515
|
-
|
|
36516
|
-
|
|
36517
|
-
|
|
36518
|
-
}
|
|
36519
|
-
|
|
36520
|
-
|
|
36521
|
-
|
|
36522
|
-
|
|
36523
|
-
|
|
36524
|
-
|
|
36525
|
-
|
|
36526
|
-
|
|
36527
|
-
|
|
36528
|
-
for (const row of rows) {
|
|
36529
|
-
const key = row.keys.join("\0");
|
|
36530
|
-
const found = merged.get(key);
|
|
36531
|
-
if (!found) {
|
|
36532
|
-
merged.set(key, { ...row });
|
|
36533
|
-
continue;
|
|
36534
|
-
}
|
|
36535
|
-
const impressions = found.impressions + row.impressions;
|
|
36536
|
-
found.position = impressions > 0 ? (found.position * found.impressions + row.position * row.impressions) / impressions : found.position;
|
|
36537
|
-
found.clicks += row.clicks;
|
|
36538
|
-
found.impressions = impressions;
|
|
36539
|
-
found.ctr = impressions > 0 ? found.clicks / impressions : 0;
|
|
36939
|
+
function isClickySiteId(value) {
|
|
36940
|
+
return /^\d{1,30}$/u.test(value.trim());
|
|
36941
|
+
}
|
|
36942
|
+
function isClickySitekey(value) {
|
|
36943
|
+
return /^[A-Za-z0-9]{12,64}$/u.test(value.trim());
|
|
36944
|
+
}
|
|
36945
|
+
async function clickyStats(site, types, options = {}) {
|
|
36946
|
+
const url = new URL(ENDPOINT$1);
|
|
36947
|
+
url.searchParams.set("site_id", site.siteId);
|
|
36948
|
+
url.searchParams.set("sitekey", site.sitekey);
|
|
36949
|
+
url.searchParams.set("type", types.join(","));
|
|
36950
|
+
url.searchParams.set("output", "json");
|
|
36951
|
+
url.searchParams.set("limit", String(options.limit ?? 1e3));
|
|
36952
|
+
url.searchParams.set("app", "seo");
|
|
36953
|
+
if (options.date) url.searchParams.set("date", options.date);
|
|
36954
|
+
const response = await fetch(url).catch(() => {
|
|
36955
|
+
throw new ClickyError("remote", "Could not reach Clicky. Try again.");
|
|
36956
|
+
});
|
|
36957
|
+
if (!response.ok) throw new ClickyError("remote", `Clicky returned ${response.status}.`);
|
|
36958
|
+
const groups = await readJson(response, "Clicky", { maxBytes: MAX_BYTES$1 });
|
|
36959
|
+
if (!Array.isArray(groups)) throw new ClickyError("remote", "Clicky returned invalid data.");
|
|
36960
|
+
const failed = groups.find((group) => typeof group?.error === "string");
|
|
36961
|
+
if (failed?.error) throw /sitekey|site id|authenticat|permission/iu.test(failed.error) ? new ClickyError("auth", `Clicky rejected site ID ${site.siteId} or its sitekey.`) : new ClickyError("remote", `Clicky: ${failed.error.slice(0, 200)}`);
|
|
36962
|
+
const out = {};
|
|
36963
|
+
for (const type of types) out[type] = [];
|
|
36964
|
+
for (const group of groups) {
|
|
36965
|
+
if (!group?.type || !(group.type in out)) continue;
|
|
36966
|
+
for (const date of group.dates ?? []) for (const item of date.items ?? []) if (item && typeof item === "object") out[group.type]?.push(item);
|
|
36540
36967
|
}
|
|
36541
|
-
return
|
|
36542
|
-
}
|
|
36543
|
-
function sharedReader(read, redirects = /* @__PURE__ */ new Map()) {
|
|
36544
|
-
const memo = /* @__PURE__ */ new Map();
|
|
36545
|
-
const merged = async (site, body, opts) => {
|
|
36546
|
-
const result = await read(site, body, opts);
|
|
36547
|
-
const pageAt = (body.dimensions ?? []).indexOf("page");
|
|
36548
|
-
if (pageAt < 0 || redirects.size === 0) return result;
|
|
36549
|
-
return {
|
|
36550
|
-
...result,
|
|
36551
|
-
rows: mergeRows(result.rows.map((row) => {
|
|
36552
|
-
const target = redirects.get(row.keys[pageAt] ?? "");
|
|
36553
|
-
if (!target) return row;
|
|
36554
|
-
const keys = [...row.keys];
|
|
36555
|
-
keys[pageAt] = target;
|
|
36556
|
-
return {
|
|
36557
|
-
...row,
|
|
36558
|
-
keys
|
|
36559
|
-
};
|
|
36560
|
-
}))
|
|
36561
|
-
};
|
|
36562
|
-
};
|
|
36563
|
-
return (site, body, opts) => {
|
|
36564
|
-
const key = JSON.stringify([site, body]);
|
|
36565
|
-
let found = memo.get(key);
|
|
36566
|
-
if (!found) {
|
|
36567
|
-
found = merged(site, body, opts);
|
|
36568
|
-
memo.set(key, found);
|
|
36569
|
-
}
|
|
36570
|
-
return found;
|
|
36571
|
-
};
|
|
36968
|
+
return out;
|
|
36572
36969
|
}
|
|
36573
|
-
async function
|
|
36574
|
-
|
|
36575
|
-
|
|
36576
|
-
|
|
36577
|
-
return findOverlappingSearchUpdates({
|
|
36578
|
-
updates: searchUpdatesFromIncidents(incidents, {
|
|
36579
|
-
product: "Ranking",
|
|
36580
|
-
limit: 50
|
|
36581
|
-
}),
|
|
36582
|
-
startDate: window.start,
|
|
36583
|
-
endDate: window.end,
|
|
36584
|
-
paddingDays: 3
|
|
36970
|
+
async function clickyDomain(site) {
|
|
36971
|
+
const { pages } = await clickyStats(site, ["pages"], {
|
|
36972
|
+
date: "last-30-days",
|
|
36973
|
+
limit: 1
|
|
36585
36974
|
});
|
|
36586
|
-
|
|
36587
|
-
|
|
36588
|
-
const window = checked;
|
|
36589
|
-
if (!window?.start || !window.end) return "in this window";
|
|
36590
|
-
const dates = window.start === window.end ? window.start : `${window.start} to ${window.end}`;
|
|
36591
|
-
return `within ${window.paddingDays ?? 3} days of ${dates}`;
|
|
36592
|
-
}
|
|
36593
|
-
function listOfUpdates(value, checked) {
|
|
36594
|
-
if (typeof value === "string") return value;
|
|
36595
|
-
if (!Array.isArray(value)) return null;
|
|
36596
|
-
const where = checkedDates(checked);
|
|
36597
|
-
if (value.length === 0) return `Google updates ${where}: none.`;
|
|
36598
|
-
return `Google updates ${where} (timing only, not cause): ${value.map((update) => `${update.name} (${update.start.slice(0, 10)} to ${update.end?.slice(0, 10) ?? "ongoing"})`).join("; ")}.`;
|
|
36599
|
-
}
|
|
36600
|
-
function matchProperty(names, site) {
|
|
36601
|
-
if (names.includes(site)) return site;
|
|
36602
|
-
let host;
|
|
36603
|
-
let origin;
|
|
36975
|
+
const url = pages?.find((item) => item.url)?.url;
|
|
36976
|
+
if (!url) return null;
|
|
36604
36977
|
try {
|
|
36605
|
-
|
|
36606
|
-
host = url.hostname.replace(/^www\./, "");
|
|
36607
|
-
origin = url.origin;
|
|
36978
|
+
return new URL(url).hostname.replace(/^www\./u, "");
|
|
36608
36979
|
} catch {
|
|
36609
36980
|
return null;
|
|
36610
36981
|
}
|
|
36611
|
-
const domain = names.filter((name) => name.startsWith("sc-domain:")).map((name) => name.slice(10)).filter((name) => host === name || host.endsWith(`.${name}`)).sort((a, b) => b.length - a.length)[0];
|
|
36612
|
-
if (domain) return `sc-domain:${domain}`;
|
|
36613
|
-
return names.find((name) => name === `${origin}/` || name === `${origin.replace("://", "://www.")}/` || name === `${origin.replace("://www.", "://")}/`) ?? null;
|
|
36614
36982
|
}
|
|
36615
36983
|
function rowsText(input) {
|
|
36616
36984
|
const total = input.rows.length;
|
|
@@ -36796,8 +37164,8 @@ async function runAnalyticsRows(input, google, options) {
|
|
|
36796
37164
|
//#endregion
|
|
36797
37165
|
//#region packages/core/dist/commands/bing.js
|
|
36798
37166
|
const BING_LIMITS = ["Bing and Google count clicks, impressions and positions their own way. Their numbers sit side by side here and are never added together.", "Bing lists only its top queries and pages, grouped by week. A query or page missing from Bing’s list is unknown, not zero."];
|
|
36799
|
-
const LOCAL_BING =
|
|
36800
|
-
const NO_BING_HERE = "Bing: not included here, so these totals show Google only. Some sites get most of their search visits from Bing;
|
|
37167
|
+
const LOCAL_BING = `Bing blocks the hosted server, so Bing data comes from the CLI on a machine. If you have a shell, link the CLI to the same account once: run \`npx -y seo login\`, show the person the link it prints, and after they approve it run \`npx -y seo login\` again. \`npx -y seo\` commands then use the same Google accounts, Bing and keys. Connect Bing on the account page if it is not connected, then run \`npx -y seo report --site <site> --section bing --json\`.`;
|
|
37168
|
+
const NO_BING_HERE = "Bing: not included here, so these totals show Google only. Some sites get most of their search visits from Bing; the CLI reads them with the same account: `npx -y seo login` once, then `npx -y seo report --site <site> --section bing --json`.";
|
|
36801
37169
|
const BING_HOME = "https://www.bing.com/webmasters";
|
|
36802
37170
|
const noBingData = (site) => `Bing has no data for ${site} yet. Newly added or newly verified sites take a few days to show data in Bing Webmaster Tools.`;
|
|
36803
37171
|
const sitesSeen = /* @__PURE__ */ new WeakMap();
|
|
@@ -37014,6 +37382,161 @@ async function runBingRows(input, deps) {
|
|
|
37014
37382
|
};
|
|
37015
37383
|
}
|
|
37016
37384
|
//#endregion
|
|
37385
|
+
//#region packages/core/dist/commands/clicky.js
|
|
37386
|
+
const CLICKY_KEY_PREFIX = "x-clicky-";
|
|
37387
|
+
function clickySites(keys) {
|
|
37388
|
+
const sites = [];
|
|
37389
|
+
for (const [name, value] of keys) {
|
|
37390
|
+
if (!name.startsWith("x-clicky-")) continue;
|
|
37391
|
+
const siteId = name.slice(9);
|
|
37392
|
+
if (!isClickySiteId(siteId)) continue;
|
|
37393
|
+
let sitekey = value.trim();
|
|
37394
|
+
let domain = null;
|
|
37395
|
+
let account = false;
|
|
37396
|
+
if (sitekey.startsWith("{")) try {
|
|
37397
|
+
const parsed = JSON.parse(sitekey);
|
|
37398
|
+
sitekey = typeof parsed.sitekey === "string" ? parsed.sitekey : "";
|
|
37399
|
+
domain = typeof parsed.domain === "string" ? parsed.domain : null;
|
|
37400
|
+
account = parsed.account === true;
|
|
37401
|
+
} catch {
|
|
37402
|
+
continue;
|
|
37403
|
+
}
|
|
37404
|
+
if (isClickySitekey(sitekey)) sites.push({
|
|
37405
|
+
siteId,
|
|
37406
|
+
sitekey,
|
|
37407
|
+
domain,
|
|
37408
|
+
account
|
|
37409
|
+
});
|
|
37410
|
+
}
|
|
37411
|
+
return sites.sort((left, right) => left.siteId < right.siteId ? -1 : left.siteId > right.siteId ? 1 : 0);
|
|
37412
|
+
}
|
|
37413
|
+
const clickyLabel = (site) => site.domain ? `${site.siteId} (${site.domain})` : site.siteId;
|
|
37414
|
+
function hostOf(value) {
|
|
37415
|
+
const text = value.trim().toLowerCase().replace(/^sc-domain:/u, "");
|
|
37416
|
+
try {
|
|
37417
|
+
return new URL(text.includes("://") ? text : `https://${text}`).hostname.replace(/^www\./u, "");
|
|
37418
|
+
} catch {
|
|
37419
|
+
return text;
|
|
37420
|
+
}
|
|
37421
|
+
}
|
|
37422
|
+
async function pickClickySite(saved, target, connect, refresh) {
|
|
37423
|
+
const host = hostOf(target);
|
|
37424
|
+
const match = (site) => site.siteId === target.trim() || site.domain === host;
|
|
37425
|
+
let sites = saved;
|
|
37426
|
+
if (!sites.some(match) && refresh) sites = await refresh() ?? sites;
|
|
37427
|
+
if (!sites.length) throw new CommandError("NO_KEY", `No Clicky site is saved. Add your Clicky account, or one site's ID and sitekey (Clicky, Preferences, Info): ${connect}`);
|
|
37428
|
+
const found = sites.find(match);
|
|
37429
|
+
if (found) return found;
|
|
37430
|
+
if (!isClickySiteId(target)) {
|
|
37431
|
+
for (const site of sites.filter((item) => !item.domain).slice(0, 10)) site.domain = await clickyDomain(site).catch(() => null);
|
|
37432
|
+
const named = sites.find(match);
|
|
37433
|
+
if (named) return named;
|
|
37434
|
+
}
|
|
37435
|
+
throw new CommandError("NO_ACCESS", `No saved Clicky site matches ${target}. Saved Clicky sites: ${sites.map(clickyLabel).join(", ")}. Pass one of these site IDs, or add the site: ${connect}`);
|
|
37436
|
+
}
|
|
37437
|
+
const DAY = 864e5;
|
|
37438
|
+
const isoDay = (time) => new Date(time).toISOString().slice(0, 10);
|
|
37439
|
+
function clickyRange(input) {
|
|
37440
|
+
if (!input.startDate && !input.endDate) {
|
|
37441
|
+
const end = Date.now() - DAY;
|
|
37442
|
+
return {
|
|
37443
|
+
startDate: isoDay(end - 27 * DAY),
|
|
37444
|
+
endDate: isoDay(end)
|
|
37445
|
+
};
|
|
37446
|
+
}
|
|
37447
|
+
const start = Date.parse(`${input.startDate}T00:00:00Z`);
|
|
37448
|
+
const end = Date.parse(`${input.endDate}T00:00:00Z`);
|
|
37449
|
+
if (!input.startDate || !input.endDate || !(start <= end)) throw new CommandError("INVALID_INPUT", "Send a start date and an end date together, in order.");
|
|
37450
|
+
if ((end - start) / DAY + 1 > 31) throw new CommandError("INVALID_INPUT", "Clicky reads at most 31 days at a time. Send a shorter range.");
|
|
37451
|
+
return {
|
|
37452
|
+
startDate: input.startDate,
|
|
37453
|
+
endDate: input.endDate
|
|
37454
|
+
};
|
|
37455
|
+
}
|
|
37456
|
+
const count = (item) => {
|
|
37457
|
+
const value = Number(item.value);
|
|
37458
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
37459
|
+
};
|
|
37460
|
+
const totals = (items) => items.map((item) => ({
|
|
37461
|
+
name: item.title ?? "",
|
|
37462
|
+
visits: count(item)
|
|
37463
|
+
})).filter((item) => Boolean(item.name) && item.visits !== null);
|
|
37464
|
+
const line = (items) => {
|
|
37465
|
+
const top = items.slice(0, 10).map((item) => `${item.name} ${item.visits}`);
|
|
37466
|
+
const rest = items.slice(10);
|
|
37467
|
+
if (rest.length) top.push(`${rest.length} others ${rest.reduce((sum, item) => sum + item.visits, 0)}`);
|
|
37468
|
+
return top.join("; ");
|
|
37469
|
+
};
|
|
37470
|
+
const LIMIT = 1e3;
|
|
37471
|
+
async function runClickyRows(input, keys, options) {
|
|
37472
|
+
const refresh = options.refresh;
|
|
37473
|
+
const site = await pickClickySite(clickySites(keys), input.site, options.connect, refresh && (async () => {
|
|
37474
|
+
const fresh = await refresh().catch(() => null);
|
|
37475
|
+
return fresh && clickySites(fresh);
|
|
37476
|
+
}));
|
|
37477
|
+
const range = clickyRange(input);
|
|
37478
|
+
const offset = input.offset ?? 0;
|
|
37479
|
+
const stats = await clickyStats(site, [
|
|
37480
|
+
"pages-entrance",
|
|
37481
|
+
"traffic-sources",
|
|
37482
|
+
"searches-engines"
|
|
37483
|
+
], {
|
|
37484
|
+
date: `${range.startDate},${range.endDate}`,
|
|
37485
|
+
limit: LIMIT
|
|
37486
|
+
}).catch((error) => {
|
|
37487
|
+
if (error instanceof ClickyError) throw new CommandError(error.code === "auth" ? "NO_ACCESS" : "PROVIDER_ERROR", error.code === "auth" ? `${error.message} Save it again: ${options.connect}` : error.message);
|
|
37488
|
+
throw error;
|
|
37489
|
+
});
|
|
37490
|
+
const entrances = stats["pages-entrance"] ?? [];
|
|
37491
|
+
const visitsByPage = /* @__PURE__ */ new Map();
|
|
37492
|
+
for (const item of entrances) {
|
|
37493
|
+
const visits = count(item);
|
|
37494
|
+
if (visits === null || !item.url) continue;
|
|
37495
|
+
let page;
|
|
37496
|
+
try {
|
|
37497
|
+
page = new URL(item.url).pathname;
|
|
37498
|
+
} catch {
|
|
37499
|
+
continue;
|
|
37500
|
+
}
|
|
37501
|
+
visitsByPage.set(page, (visitsByPage.get(page) ?? 0) + visits);
|
|
37502
|
+
}
|
|
37503
|
+
const rows = [...visitsByPage].map(([page, visits]) => ({
|
|
37504
|
+
page,
|
|
37505
|
+
visits
|
|
37506
|
+
})).sort((left, right) => right.visits - left.visits || (left.page < right.page ? -1 : left.page > right.page ? 1 : 0));
|
|
37507
|
+
const sources = totals(stats["traffic-sources"] ?? []);
|
|
37508
|
+
const engines = totals(stats["searches-engines"] ?? []);
|
|
37509
|
+
const notes = [
|
|
37510
|
+
"Visits by landing page from every source, as Clicky counts them, in the Clicky site’s time zone. Clicky does not split landing pages by source.",
|
|
37511
|
+
sources.length ? `Traffic sources (visits): ${line(sources)}.` : "Traffic sources: none in this range.",
|
|
37512
|
+
engines.length ? `Search engines (visits): ${line(engines)}.` : "Search engines: none in this range."
|
|
37513
|
+
];
|
|
37514
|
+
if (entrances.length >= LIMIT) notes.push(`Clicky returned its ${LIMIT}-row limit; pages past it are missing, not zero.`);
|
|
37515
|
+
return {
|
|
37516
|
+
text: rowsText({
|
|
37517
|
+
title: `Clicky site ${clickyLabel(site)}: landing pages, ${range.startDate} to ${range.endDate}.`,
|
|
37518
|
+
rows,
|
|
37519
|
+
offset,
|
|
37520
|
+
limit: 50,
|
|
37521
|
+
detail: input.detail ?? "summary",
|
|
37522
|
+
notes,
|
|
37523
|
+
more: options.call
|
|
37524
|
+
}),
|
|
37525
|
+
data: {
|
|
37526
|
+
siteId: site.siteId,
|
|
37527
|
+
domain: site.domain,
|
|
37528
|
+
range,
|
|
37529
|
+
metric: "visits",
|
|
37530
|
+
sources,
|
|
37531
|
+
searchEngines: engines,
|
|
37532
|
+
capped: entrances.length >= LIMIT,
|
|
37533
|
+
total: rows.length,
|
|
37534
|
+
offset,
|
|
37535
|
+
rows: rows.slice(offset, offset + 50)
|
|
37536
|
+
}
|
|
37537
|
+
};
|
|
37538
|
+
}
|
|
37539
|
+
//#endregion
|
|
37017
37540
|
//#region packages/core/dist/commands/domain-rating.js
|
|
37018
37541
|
const DR_ATTRIBUTION = "domain_rating: Domain Rating by Ahrefs (https://ahrefs.com/).";
|
|
37019
37542
|
const ENDPOINT = "https://api.ahrefs.com/v3/public/domain-rating-free";
|
|
@@ -37239,7 +37762,7 @@ function nextFor(section, call) {
|
|
|
37239
37762
|
"second-page": `Next: ${call("page")} for one page’s queries and live facts.`,
|
|
37240
37763
|
"content-decay": `Next: ${call("index")} for pages that lost most of their clicks; ${call("page")} to see which queries moved.`,
|
|
37241
37764
|
cannibalization: `Next: ${call("page")} on each competing URL to see Google’s chosen canonical.`,
|
|
37242
|
-
"traffic-drop": `Next: ${call("compare")} with the date the drop started, for the pages and queries that moved.`,
|
|
37765
|
+
"traffic-drop": `Next: ${call("compare")} with the date the drop started, for the pages and queries that moved; ${call("index")} for pages that lost almost all their clicks and their index status.`,
|
|
37243
37766
|
bing: `Next: ${call("data", { source: "bing" })} for every Bing row; ${call("page")} for one page.`
|
|
37244
37767
|
}[section];
|
|
37245
37768
|
}
|
|
@@ -37614,13 +38137,9 @@ function runReport$1(input, deps) {
|
|
|
37614
38137
|
}
|
|
37615
38138
|
//#endregion
|
|
37616
38139
|
//#region packages/core/dist/commands/research-providers.js
|
|
37617
|
-
const KEY_HEADERS = {
|
|
37618
|
-
dataforseo: "X-DataForSEO-Login and X-DataForSEO-Password",
|
|
37619
|
-
ahrefs: "X-Ahrefs-Key"
|
|
37620
|
-
};
|
|
37621
38140
|
function need(headers, provider) {
|
|
37622
38141
|
const values = provider === "dataforseo" ? [headers.get("x-dataforseo-login"), headers.get("x-dataforseo-password")] : [headers.get("x-ahrefs-key")];
|
|
37623
|
-
if (values.some((value) => !value)) throw new CommandError("NO_KEY", `No ${provider} key. Hosted MCP: save it at https://seoskill.dev/account#keys
|
|
38142
|
+
if (values.some((value) => !value)) throw new CommandError("NO_KEY", `No ${provider} key. Hosted MCP: save it at https://seoskill.dev/account#keys. CLI and local MCP: run \`seo login\` to use the account's keys, or \`seo providers ${provider} connect\`.`);
|
|
37624
38143
|
return values;
|
|
37625
38144
|
}
|
|
37626
38145
|
async function call(provider, request) {
|
|
@@ -38033,7 +38552,39 @@ const LOST_SHARE = .2;
|
|
|
38033
38552
|
const MIN_LOST = 3;
|
|
38034
38553
|
const CHECKED = 20;
|
|
38035
38554
|
const INSPECTED = 10;
|
|
38036
|
-
const
|
|
38555
|
+
const SITE_WIDE_MIN = 5;
|
|
38556
|
+
function indexSummary(inspected, home) {
|
|
38557
|
+
const known = inspected.filter((item) => item?.verdict != null);
|
|
38558
|
+
const indexed = known.filter((item) => item.verdict === "PASS").length;
|
|
38559
|
+
const coverage = /* @__PURE__ */ new Map();
|
|
38560
|
+
for (const item of known) {
|
|
38561
|
+
if (item.verdict === "PASS") continue;
|
|
38562
|
+
const state = item.coverage ?? item.verdict ?? "unknown";
|
|
38563
|
+
coverage.set(state, (coverage.get(state) ?? 0) + 1);
|
|
38564
|
+
}
|
|
38565
|
+
const notIndexed = known.length - indexed;
|
|
38566
|
+
const homeIndexed = home?.status?.verdict ? home.status.verdict === "PASS" : null;
|
|
38567
|
+
const siteWide = notIndexed >= SITE_WIDE_MIN && notIndexed * 2 > known.length && homeIndexed;
|
|
38568
|
+
const byCoverage = [...coverage].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([state, count]) => ({
|
|
38569
|
+
state,
|
|
38570
|
+
count
|
|
38571
|
+
}));
|
|
38572
|
+
return {
|
|
38573
|
+
text: (known.length ? [`Index status of the ${known.length} inspected pages: ${indexed} indexed, ${notIndexed} not indexed${byCoverage.length ? ` (${byCoverage.map((item) => `${item.count} ${item.state}`).join("; ")})` : ""}.${home && homeIndexed !== null ? ` Home page ${home.url}: ${homeIndexed ? "indexed" : `not indexed (${home.status?.coverage ?? home.status?.verdict})`}.` : ""}`, siteWide ? `Site-wide pattern: ${notIndexed} of ${known.length} inspected pages are not in Google's index, but the home page is. This points to the whole site, not single pages. It does not show the cause.` : null] : []).filter(Boolean).join("\n"),
|
|
38574
|
+
data: {
|
|
38575
|
+
inspected: known.length,
|
|
38576
|
+
indexed,
|
|
38577
|
+
notIndexed,
|
|
38578
|
+
byCoverage,
|
|
38579
|
+
homePage: home && homeIndexed !== null ? {
|
|
38580
|
+
url: home.url,
|
|
38581
|
+
indexed: homeIndexed
|
|
38582
|
+
} : null,
|
|
38583
|
+
siteWide: Boolean(siteWide)
|
|
38584
|
+
}
|
|
38585
|
+
};
|
|
38586
|
+
}
|
|
38587
|
+
const LOST_PAGES_RULE = `Pages that had at least ${MIN_LOST} clicks in the previous 28 days and kept ${LOST_SHARE * 100}% or less of them in the last 28 days, with a live check of the top ${CHECKED} and Google’s index status for the top ${INSPECTED} and the home page.`;
|
|
38037
38588
|
async function runLostPages(input, deps) {
|
|
38038
38589
|
const range = dateWindow(input, 28);
|
|
38039
38590
|
const before = earlierRange(range);
|
|
@@ -38051,7 +38602,17 @@ async function runLostPages(input, deps) {
|
|
|
38051
38602
|
clicks: current.get(row.keys[0]) ?? 0
|
|
38052
38603
|
})).filter((row) => row.clicks <= row.previous_clicks * LOST_SHARE).sort((a, b) => b.previous_clicks - b.clicks - (a.previous_clicks - a.clicks) || a.url.localeCompare(b.url));
|
|
38053
38604
|
const top = lost.slice(0, CHECKED);
|
|
38054
|
-
const
|
|
38605
|
+
const homeUrl = top[0] ? `${new URL(top[0].url).origin}/` : null;
|
|
38606
|
+
const [facts, indexed, homeStatus] = await Promise.all([
|
|
38607
|
+
deps.checkPages(top.map((row) => row.url)),
|
|
38608
|
+
Promise.all(top.slice(0, INSPECTED).map((row) => deps.inspect(input.site, row.url))),
|
|
38609
|
+
homeUrl && !top.slice(0, INSPECTED).some((row) => row.url === homeUrl) ? deps.inspect(input.site, homeUrl).catch(() => null) : null
|
|
38610
|
+
]);
|
|
38611
|
+
const homeIndex = top.slice(0, INSPECTED).findIndex((row) => row.url === homeUrl);
|
|
38612
|
+
const summary = indexSummary(homeIndex >= 0 ? indexed.filter((_, index) => index !== homeIndex) : indexed, homeUrl ? {
|
|
38613
|
+
url: homeUrl,
|
|
38614
|
+
status: homeIndex >= 0 ? indexed[homeIndex] ?? null : homeStatus
|
|
38615
|
+
} : null);
|
|
38055
38616
|
const rows = top.map((row, index) => ({
|
|
38056
38617
|
...row,
|
|
38057
38618
|
status: facts[index]?.status ?? null,
|
|
@@ -38067,6 +38628,7 @@ async function runLostPages(input, deps) {
|
|
|
38067
38628
|
`Pages that lost their clicks on ${input.site}: ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
|
|
38068
38629
|
`${lost.length} pages kept ${LOST_SHARE * 100}% or less of at least ${MIN_LOST} clicks${lost.length > CHECKED ? `; the top ${CHECKED} by clicks lost are shown` : ""}.`,
|
|
38069
38630
|
rows.length ? table(rows, Object.keys(rows[0] ?? {}), originOf(input.site)) : "No pages lost their clicks.",
|
|
38631
|
+
summary.text,
|
|
38070
38632
|
"Live checks are fetched now with no redirects followed. Index status is from URL Inspection; Google allows about 2,000 inspections a day per property.",
|
|
38071
38633
|
temporaryRedirectNote(rows),
|
|
38072
38634
|
`Next: ${deps.call("page")} for one URL; ${deps.call("report", {
|
|
@@ -38080,84 +38642,12 @@ async function runLostPages(input, deps) {
|
|
|
38080
38642
|
comparisonRange: before,
|
|
38081
38643
|
rule: LOST_PAGES_RULE,
|
|
38082
38644
|
lostPages: lost.length,
|
|
38645
|
+
index: summary.data,
|
|
38083
38646
|
pages: rows
|
|
38084
38647
|
}
|
|
38085
38648
|
};
|
|
38086
38649
|
}
|
|
38087
38650
|
//#endregion
|
|
38088
|
-
//#region packages/core/dist/commands/sites.js
|
|
38089
|
-
async function readableSites(google, signIn) {
|
|
38090
|
-
return (await listSearchConsoleSites(google, { signIn })).filter((entry) => entry.permissionLevel !== "siteUnverifiedUser").map((entry) => ({
|
|
38091
|
-
siteUrl: entry.siteUrl,
|
|
38092
|
-
permissionLevel: entry.permissionLevel ?? ""
|
|
38093
|
-
})).sort((a, b) => a.siteUrl.localeCompare(b.siteUrl));
|
|
38094
|
-
}
|
|
38095
|
-
const hostOf = (value) => {
|
|
38096
|
-
try {
|
|
38097
|
-
return new URL(value.includes("://") ? value : `https://${value.replace(/^sc-domain:/, "")}`).hostname.replace(/^www\./, "");
|
|
38098
|
-
} catch {
|
|
38099
|
-
return value;
|
|
38100
|
-
}
|
|
38101
|
-
};
|
|
38102
|
-
async function runSites(input) {
|
|
38103
|
-
const named = input.accounts.length > 1;
|
|
38104
|
-
const [google, bing] = await Promise.all([Promise.all(input.accounts.map(async (account) => {
|
|
38105
|
-
const [properties, analytics] = await Promise.all([readableSites(account.google, input.signIn).catch((error) => error instanceof Error ? error : new Error(String(error))), listAnalyticsAccounts(account.google, { signIn: input.signIn }).catch(() => null)]);
|
|
38106
|
-
return {
|
|
38107
|
-
email: account.email,
|
|
38108
|
-
properties,
|
|
38109
|
-
analytics
|
|
38110
|
-
};
|
|
38111
|
-
})), input.bing ? listBingSites(input.bing).catch((error) => error instanceof Error ? error : new Error(String(error))) : Promise.resolve(null)]);
|
|
38112
|
-
const bingSites = Array.isArray(bing) ? bing.filter((site) => site.verified) : [];
|
|
38113
|
-
const bingByHost = new Map(bingSites.map((site) => [hostOf(site.url), site]));
|
|
38114
|
-
const properties = google.flatMap((account) => account.properties instanceof Error ? [] : account.properties.map((property) => ({
|
|
38115
|
-
...property,
|
|
38116
|
-
...named ? { account: account.email ?? "" } : {},
|
|
38117
|
-
...Array.isArray(bing) ? { bing: bingByHost.get(hostOf(property.siteUrl))?.url ?? "" } : {}
|
|
38118
|
-
})));
|
|
38119
|
-
const googleHosts = new Set(properties.map((property) => hostOf(property.siteUrl)));
|
|
38120
|
-
const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf(site.url)));
|
|
38121
|
-
const failed = google.filter((account) => account.properties instanceof Error);
|
|
38122
|
-
const analytics = google.flatMap((account) => (account.analytics ?? []).flatMap((summary) => summary.propertySummaries.map((property) => ({
|
|
38123
|
-
property: property.property.replace("properties/", ""),
|
|
38124
|
-
name: property.displayName ?? "",
|
|
38125
|
-
account: summary.displayName ?? "",
|
|
38126
|
-
...named ? { signIn: account.email ?? "" } : {}
|
|
38127
|
-
}))));
|
|
38128
|
-
const research = [["dataforseo", "x-dataforseo-login"], ["ahrefs", "x-ahrefs-key"]].filter(([, name]) => input.keys.get(name ?? "")).map(([provider]) => provider);
|
|
38129
|
-
const columns = [
|
|
38130
|
-
"siteUrl",
|
|
38131
|
-
"permissionLevel",
|
|
38132
|
-
...named ? ["account"] : [],
|
|
38133
|
-
...Array.isArray(bing) ? ["bing"] : []
|
|
38134
|
-
];
|
|
38135
|
-
return {
|
|
38136
|
-
text: [
|
|
38137
|
-
named ? `Google accounts: ${input.accounts.map((account) => account.email).join(", ")}.` : null,
|
|
38138
|
-
`Search Console properties (${properties.length}):`,
|
|
38139
|
-
properties.length ? table(properties, columns, null) : "None. These Google sign-ins have no Search Console access, so the reports cannot run.",
|
|
38140
|
-
...failed.map((account) => `${account.email ?? "Google"}: could not list properties. ${account.properties.message}`),
|
|
38141
|
-
analytics.length ? `Google Analytics properties (${analytics.length}):\n${table(analytics, [
|
|
38142
|
-
"property",
|
|
38143
|
-
"name",
|
|
38144
|
-
"account",
|
|
38145
|
-
...named ? ["signIn"] : []
|
|
38146
|
-
], null)}` : "Google Analytics: no properties readable with these sign-ins.",
|
|
38147
|
-
input.bing === void 0 ? null : bing === null ? "Bing Webmaster: not connected." : bing instanceof Error ? `Bing Webmaster: could not list sites. ${bing.message}` : bingOnly.length ? `Bing Webmaster sites with no Search Console property here (${bingOnly.length}): ${bingOnly.map((site) => site.url).join(", ")}.` : `Bing Webmaster: ${bingSites.length} verified sites; the bing column shows each one.`,
|
|
38148
|
-
`Research keys: ${research.length ? research.join(", ") : "none"}.`,
|
|
38149
|
-
`Add a Google account${input.bing === void 0 ? "" : ", Bing"} or a key: ${input.connect}`,
|
|
38150
|
-
"Next: seo_report with a site."
|
|
38151
|
-
].filter(Boolean).join("\n\n"),
|
|
38152
|
-
data: {
|
|
38153
|
-
searchConsole: properties,
|
|
38154
|
-
googleAnalytics: analytics,
|
|
38155
|
-
bing: Array.isArray(bing) ? bingSites : null,
|
|
38156
|
-
research
|
|
38157
|
-
}
|
|
38158
|
-
};
|
|
38159
|
-
}
|
|
38160
|
-
//#endregion
|
|
38161
38651
|
//#region packages/core/dist/commands/telemetry-report.js
|
|
38162
38652
|
const SECTION_REPORTS = {
|
|
38163
38653
|
overview: "search-performance-overview",
|
|
@@ -38205,9 +38695,9 @@ const detail = {
|
|
|
38205
38695
|
const DATA_SOURCES = [
|
|
38206
38696
|
"search_console",
|
|
38207
38697
|
"google_analytics",
|
|
38208
|
-
"bing"
|
|
38698
|
+
"bing",
|
|
38699
|
+
"clicky"
|
|
38209
38700
|
];
|
|
38210
|
-
const LOCAL_ANALYTICS = "Clicky and other analytics run on the person’s machine with the CLI and a provider package: `npx -y seo providers install @seoskill/clicky-provider`, connect it once with `seo providers connect clicky`, then `npx -y seo analytics clicky report --json`. `seo providers list` shows installed providers.";
|
|
38211
38701
|
function tool(spec) {
|
|
38212
38702
|
return spec;
|
|
38213
38703
|
}
|
|
@@ -38221,6 +38711,7 @@ const SHARED_TOOLS = [
|
|
|
38221
38711
|
run: async (surface) => runSites({
|
|
38222
38712
|
accounts: await surface.accounts(),
|
|
38223
38713
|
keys: await surface.keys(),
|
|
38714
|
+
clicky: clickySites(await surface.keys()).map(clickyLabel),
|
|
38224
38715
|
signIn: surface.signIn,
|
|
38225
38716
|
bing: surface.deps.bing ? await surface.deps.bing() : void 0,
|
|
38226
38717
|
connect: surface.connect
|
|
@@ -38300,7 +38791,7 @@ const SHARED_TOOLS = [
|
|
|
38300
38791
|
name: "seo_research",
|
|
38301
38792
|
command: "research",
|
|
38302
38793
|
title: "Keyword, competitor and backlink data",
|
|
38303
|
-
description: `Data from the person’s own DataForSEO or Ahrefs account, which costs them credits.
|
|
38794
|
+
description: `Data from the person’s own DataForSEO or Ahrefs account, which costs them credits. It uses the key the person saved on their seoskill.dev account (or on this machine for the CLI). Actions: ${RESEARCH_HELP}`,
|
|
38304
38795
|
input: z.object({
|
|
38305
38796
|
provider: z.enum(RESEARCH_PROVIDERS),
|
|
38306
38797
|
action: z.enum(RESEARCH_ACTIONS),
|
|
@@ -38318,10 +38809,10 @@ const SHARED_TOOLS = [
|
|
|
38318
38809
|
name: "seo_data",
|
|
38319
38810
|
command: "data",
|
|
38320
38811
|
title: "Raw rows",
|
|
38321
|
-
description: `Raw rows for questions the report does not answer. search_console (default): rows for any dimensions, each with the previous period and the change; page and query narrow to rows whose URL or query contains that text. google_analytics: landing pages for visits from search, with Google Analytics’ own metric names. bing: Bing Webmaster query or page rows (first dimension) for the same site, by week.
|
|
38812
|
+
description: `Raw rows for questions the report does not answer. search_console (default): rows for any dimensions, each with the previous period and the change; page and query narrow to rows whose URL or query contains that text. google_analytics: landing pages for visits from search, with Google Analytics’ own metric names. bing: Bing Webmaster query or page rows (first dimension) for the same site, by week. clicky: landing-page visits, traffic sources and search engines from a Clicky site the person saved (site is its domain or Clicky site ID; at most 31 days).`,
|
|
38322
38813
|
input: z.object({
|
|
38323
38814
|
source: z.enum(DATA_SOURCES).default("search_console"),
|
|
38324
|
-
site: z.string().min(1).describe("search_console and bing: the property or site. google_analytics: numeric property id from seo_sites."),
|
|
38815
|
+
site: z.string().min(1).describe("search_console and bing: the property or site. google_analytics: numeric property id from seo_sites. clicky: the site or its Clicky site ID."),
|
|
38325
38816
|
dimensions: z.array(z.enum(DIMENSIONS)).max(4).default(["query"]).describe("search_console; bing uses the first, query or page."),
|
|
38326
38817
|
page: z.string().min(1).optional().describe("search_console only: URL text, such as /blog/."),
|
|
38327
38818
|
query: z.string().min(1).optional().describe("search_console only: query text."),
|
|
@@ -38329,6 +38820,17 @@ const SHARED_TOOLS = [
|
|
|
38329
38820
|
...detail
|
|
38330
38821
|
}),
|
|
38331
38822
|
run: async (surface, input) => {
|
|
38823
|
+
if (input.source === "clicky") return runClickyRows({
|
|
38824
|
+
site: input.site,
|
|
38825
|
+
startDate: input.start_date,
|
|
38826
|
+
endDate: input.end_date,
|
|
38827
|
+
detail: input.detail,
|
|
38828
|
+
offset: input.offset
|
|
38829
|
+
}, await surface.keys(), {
|
|
38830
|
+
connect: surface.connect,
|
|
38831
|
+
call: (offset) => `seo_data with source "clicky", site "${input.site}", detail "rows", offset ${offset}`,
|
|
38832
|
+
refresh: surface.refreshClicky
|
|
38833
|
+
});
|
|
38332
38834
|
if (input.source === "bing") return runBingRows({
|
|
38333
38835
|
site: input.site,
|
|
38334
38836
|
dimension: input.dimensions[0] === "page" ? "page" : "query",
|
|
@@ -38372,10 +38874,10 @@ async function runSharedTool(tool, surface, input) {
|
|
|
38372
38874
|
if (!parsed.success) throw new CommandError("INVALID_INPUT", parsed.error.issues.map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`).join("; "));
|
|
38373
38875
|
return tool.run(surface, parsed.data);
|
|
38374
38876
|
}
|
|
38375
|
-
const MCP_INSTRUCTIONS = `SEO Skill reads the person's own Search Console, Google Analytics, Bing Webmaster Tools and optional keyword
|
|
38877
|
+
const MCP_INSTRUCTIONS = `SEO Skill reads the person's own Search Console, Google Analytics, Bing Webmaster Tools, Clicky and optional keyword accounts, and returns facts with their dates and limits. It gives no advice.
|
|
38376
38878
|
1. seo_sites: the properties the connected accounts can read.
|
|
38377
38879
|
2. seo_report: the main report. Run it without a section first; it shows the overview and every section's count and top rows. Then ask for the one section you need.
|
|
38378
|
-
3. seo_page for one URL, seo_compare for before and after a date, seo_index for pages that lost their clicks, seo_research for keyword, competitor and backlink data from the
|
|
38880
|
+
3. seo_page for one URL, seo_compare for before and after a date, seo_index for pages that lost their clicks, seo_research for keyword, competitor and backlink data from the DataForSEO or Ahrefs key the person saved, seo_data for raw rows, including Google Analytics, Bing and Clicky.
|
|
38379
38881
|
Show the person the facts that matter, for example in a table or a small dashboard, and decide with them what to change. Do not edit pages without their approval.`;
|
|
38380
38882
|
const mcpCall = (command, args) => {
|
|
38381
38883
|
const name = `seo_${command.replace("-", "_")}`;
|
|
@@ -38585,15 +39087,34 @@ function nodeCommandDeps(options) {
|
|
|
38585
39087
|
bingConnect: LOCAL_BING_CONNECT
|
|
38586
39088
|
};
|
|
38587
39089
|
}
|
|
38588
|
-
const LOCAL_BING_CONNECT = "
|
|
39090
|
+
const LOCAL_BING_CONNECT = "Connect Bing on your seoskill.dev account and run `seo login`, or run `seo providers bing connect` with an API key from Bing Webmaster Tools (Settings, API access).";
|
|
38589
39091
|
let bing;
|
|
38590
39092
|
function localBing() {
|
|
38591
|
-
bing ??= readBingApiKey().catch(() => void 0).then((key) => key ? bingCall({ apiKey: key.apiKey }, { signIn: LOCAL_BING_CONNECT }) : null);
|
|
39093
|
+
bing ??= readBingApiKey().catch(() => void 0).then((key) => key ? bingCall({ apiKey: key.apiKey }, { signIn: LOCAL_BING_CONNECT }) : cloudLinked() ? cloudBing() : null);
|
|
38592
39094
|
return bing;
|
|
38593
39095
|
}
|
|
39096
|
+
async function localAccounts() {
|
|
39097
|
+
if (!cloudLinked()) return [{
|
|
39098
|
+
email: null,
|
|
39099
|
+
google: await localGoogleFetch()
|
|
39100
|
+
}];
|
|
39101
|
+
return (await cloudGoogleEmails()).map((email) => ({
|
|
39102
|
+
email,
|
|
39103
|
+
google: bearerFetch(() => cloudGoogleToken(email))
|
|
39104
|
+
}));
|
|
39105
|
+
}
|
|
38594
39106
|
async function localResearchKeys() {
|
|
38595
|
-
const [dataForSeo, ahrefs] = await Promise.all([
|
|
38596
|
-
|
|
39107
|
+
const [dataForSeo, ahrefs, clicky, saved] = await Promise.all([
|
|
39108
|
+
readDataForSeoCredentials().catch(() => void 0),
|
|
39109
|
+
readAhrefsApiKey().catch(() => void 0),
|
|
39110
|
+
readClickySiteKeys().catch(() => ({})),
|
|
39111
|
+
cloudLinked() ? cloudKeys().catch(() => ({})) : {}
|
|
39112
|
+
]);
|
|
39113
|
+
const keys = new Headers(saved);
|
|
39114
|
+
for (const [siteId, sitekey] of Object.entries(clicky)) {
|
|
39115
|
+
const name = `${CLICKY_KEY_PREFIX}${siteId}`;
|
|
39116
|
+
if (!keys.has(name)) keys.set(name, sitekey);
|
|
39117
|
+
}
|
|
38597
39118
|
if (dataForSeo) {
|
|
38598
39119
|
keys.set("x-dataforseo-login", dataForSeo.login);
|
|
38599
39120
|
keys.set("x-dataforseo-password", dataForSeo.password);
|
|
@@ -38601,6 +39122,11 @@ async function localResearchKeys() {
|
|
|
38601
39122
|
if (ahrefs?.apiKey) keys.set("x-ahrefs-key", ahrefs.apiKey);
|
|
38602
39123
|
return keys;
|
|
38603
39124
|
}
|
|
39125
|
+
async function localRefreshClicky() {
|
|
39126
|
+
if (!cloudLinked()) return null;
|
|
39127
|
+
await cloudCredentials({ refreshClicky: true });
|
|
39128
|
+
return localResearchKeys();
|
|
39129
|
+
}
|
|
38604
39130
|
async function resolveLocalSite(site, options = {}) {
|
|
38605
39131
|
const found = matchProperty((await listSites(options.refresh, options.account)).map((entry) => entry.siteUrl), site);
|
|
38606
39132
|
if (!found) throw new CommandError("NO_ACCESS", `This Google login has no Search Console property for ${site}. Run \`seo sites\` to list the properties it can read.`);
|
|
@@ -42117,13 +42643,11 @@ function registerSharedTools(server, options = {}) {
|
|
|
42117
42643
|
deps: nodeCommandDeps({ call: mcpCall }),
|
|
42118
42644
|
resolveSite: (site) => resolveLocalSite(site),
|
|
42119
42645
|
keys: localResearchKeys,
|
|
42646
|
+
refreshClicky: localRefreshClicky,
|
|
42120
42647
|
drKey: null,
|
|
42121
|
-
accounts:
|
|
42122
|
-
email: null,
|
|
42123
|
-
google: await localGoogleFetch()
|
|
42124
|
-
}],
|
|
42648
|
+
accounts: localAccounts,
|
|
42125
42649
|
signIn: LOCAL_SIGN_IN.signIn,
|
|
42126
|
-
connect: "run `seo
|
|
42650
|
+
connect: "run `seo login` to use your seoskill.dev account (Google, Bing and keys in one place), or `seo auth login`, `seo providers bing connect` and `seo providers dataforseo connect` or `seo providers ahrefs connect` on this machine only."
|
|
42127
42651
|
};
|
|
42128
42652
|
for (const shared of SHARED_TOOLS) server.registerTool(shared.name, {
|
|
42129
42653
|
title: shared.title,
|