@gscdump/sdk 7.12.0 → 7.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/indexing/cohorts.d.mts +201 -0
- package/dist/indexing/cohorts.mjs +343 -0
- package/dist/indexing/overview.d.mts +158 -0
- package/dist/indexing/overview.mjs +310 -0
- package/dist/indexing/portfolio-markdown.d.mts +60 -0
- package/dist/indexing/portfolio-markdown.mjs +127 -0
- package/dist/indexing/portfolio.d.mts +99 -0
- package/dist/indexing/portfolio.mjs +206 -0
- package/dist/indexing/readings.d.mts +42 -0
- package/dist/indexing/readings.mjs +47 -0
- package/dist/indexing/regression-lead.d.mts +22 -0
- package/dist/indexing/regression-lead.mjs +48 -0
- package/dist/indexing/site-issues.d.mts +15 -0
- package/dist/indexing/site-issues.mjs +85 -0
- package/dist/indexing/trust-gate.d.mts +63 -0
- package/dist/indexing/trust-gate.mjs +74 -0
- package/dist/indexing/window.d.mts +40 -0
- package/dist/indexing/window.mjs +45 -0
- package/dist/indexing.d.mts +10 -0
- package/dist/indexing.mjs +10 -0
- package/dist/package.mjs +1 -1
- package/package.json +9 -3
package/README.md
CHANGED
|
@@ -55,6 +55,19 @@ const report = await gscdump.queryAnalyticsReport({
|
|
|
55
55
|
})
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
+
`@gscdump/sdk/indexing` holds the pure indexing dashboard maths: Portfolio
|
|
59
|
+
stacks and issue counts, the Indexing window, the Indexing overview, the Trust
|
|
60
|
+
gate, Index cohorts, the Regression lead, and the Portfolio Markdown export.
|
|
61
|
+
Every function takes data and returns data. A function that needs today takes `now`.
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { indexingWindow, portfolioIssueCounts, readSiteIndexing } from '@gscdump/sdk/indexing'
|
|
65
|
+
|
|
66
|
+
const window = indexingWindow('28d', true, new Date())
|
|
67
|
+
const sites = reads.map(read => ({ id: read.siteId, label: read.name, trend: readSiteIndexing(read.response).trend }))
|
|
68
|
+
const issues = portfolioIssueCounts(sites, window)
|
|
69
|
+
```
|
|
70
|
+
|
|
58
71
|
## Browser integration
|
|
59
72
|
|
|
60
73
|
Keep `user_key` and `partner_key` credentials on your server.
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { GscdumpIndexingUrl } from "@gscdump/contracts";
|
|
2
|
+
export interface IndexCohortPage {
|
|
3
|
+
/** Site-relative path, such as `/docs/og-image/v5/guides/emojis`. */
|
|
4
|
+
path: string;
|
|
5
|
+
/** Google reports this URL as indexed. */
|
|
6
|
+
indexed: boolean;
|
|
7
|
+
inSitemap: boolean;
|
|
8
|
+
}
|
|
9
|
+
export type IndexCohortDimension = 'section' | 'lifecycle';
|
|
10
|
+
export type IndexCohortLifecycle = 'versioned' | 'evergreen';
|
|
11
|
+
/** Exact Index cohort membership, for a URL list drill-through. */
|
|
12
|
+
export type IndexCohortFilter = {
|
|
13
|
+
dimension: 'section';
|
|
14
|
+
key: string;
|
|
15
|
+
} | {
|
|
16
|
+
dimension: 'lifecycle';
|
|
17
|
+
key: IndexCohortLifecycle;
|
|
18
|
+
};
|
|
19
|
+
export interface IndexCohortCell {
|
|
20
|
+
dimension: IndexCohortDimension;
|
|
21
|
+
/** A path prefix for `section`, a lifecycle tag for `lifecycle`. */
|
|
22
|
+
key: string;
|
|
23
|
+
label: string;
|
|
24
|
+
/** The section prefix for presentation, or null for a lifecycle cohort. */
|
|
25
|
+
pathPrefix: string | null;
|
|
26
|
+
total: number;
|
|
27
|
+
notIndexed: number;
|
|
28
|
+
/** Not-indexed share within the cohort. */
|
|
29
|
+
rate: number;
|
|
30
|
+
/** Not-indexed share across every analysed page outside this cohort. */
|
|
31
|
+
complementRate: number;
|
|
32
|
+
complementTotal: number;
|
|
33
|
+
ciLower: number;
|
|
34
|
+
ciUpper: number;
|
|
35
|
+
/**
|
|
36
|
+
* How many of this cohort's not-indexed pages a higher-ranked cohort already
|
|
37
|
+
* counts. Cohorts nest, so without this a reader adds 13 and 21 and believes
|
|
38
|
+
* 34 pages are affected when 21 are.
|
|
39
|
+
*/
|
|
40
|
+
overlapNotIndexed: number;
|
|
41
|
+
}
|
|
42
|
+
export interface IndexCohortCoverage {
|
|
43
|
+
/** Pages in the analysed set: the denominator. */
|
|
44
|
+
analysed: number;
|
|
45
|
+
/** Inspected URLs dropped as `#fragment` anchors. They can never be indexed. */
|
|
46
|
+
excludedFragments: number;
|
|
47
|
+
/** Inspected URLs a Crawl never reached. Always 0 when built from inspected URLs. */
|
|
48
|
+
notCrawled: number;
|
|
49
|
+
}
|
|
50
|
+
export interface IndexCohortBaseline {
|
|
51
|
+
total: number;
|
|
52
|
+
notIndexed: number;
|
|
53
|
+
rate: number;
|
|
54
|
+
}
|
|
55
|
+
export type IndexCohortDiagnosis = {
|
|
56
|
+
_tag: 'no-evidence';
|
|
57
|
+
reason: 'no-inspection-join' | 'no-completed-crawl';
|
|
58
|
+
} | {
|
|
59
|
+
_tag: 'no-evidence';
|
|
60
|
+
reason: 'sampled-index-state';
|
|
61
|
+
enumerated: number;
|
|
62
|
+
reported: number;
|
|
63
|
+
} | {
|
|
64
|
+
_tag: 'uniform';
|
|
65
|
+
baseline: IndexCohortBaseline;
|
|
66
|
+
coverage: IndexCohortCoverage;
|
|
67
|
+
/** The corrected critical value applied. */
|
|
68
|
+
z: number;
|
|
69
|
+
tested: number;
|
|
70
|
+
} | {
|
|
71
|
+
_tag: 'outliers';
|
|
72
|
+
baseline: IndexCohortBaseline;
|
|
73
|
+
coverage: IndexCohortCoverage;
|
|
74
|
+
z: number;
|
|
75
|
+
tested: number;
|
|
76
|
+
/** Worst first. Only cohorts Google treats worse than their complement. */
|
|
77
|
+
cells: IndexCohortCell[];
|
|
78
|
+
};
|
|
79
|
+
interface IndexCohortsEnvelope {
|
|
80
|
+
/** The Crawl settings behind the cohorts. Null when built from inspected URLs. */
|
|
81
|
+
crawlSettingsId: number | null;
|
|
82
|
+
/** The caller's snapshot timestamp. */
|
|
83
|
+
asOf: string | null;
|
|
84
|
+
}
|
|
85
|
+
/** The Index cohorts wire shape nuxtseo.com and request-indexing publish. */
|
|
86
|
+
export type IndexCohortsResponse = (IndexCohortsEnvelope & {
|
|
87
|
+
_tag: 'no-evidence';
|
|
88
|
+
reason: 'no-inspection-join' | 'no-completed-crawl' | 'sampled-index-state';
|
|
89
|
+
/** Set only for `sampled-index-state`: the enumerated and reported not-indexed counts. */
|
|
90
|
+
sample: {
|
|
91
|
+
enumerated: number;
|
|
92
|
+
reported: number;
|
|
93
|
+
} | null;
|
|
94
|
+
}) | (IndexCohortsEnvelope & Extract<IndexCohortDiagnosis, {
|
|
95
|
+
_tag: 'uniform';
|
|
96
|
+
}>) | (IndexCohortsEnvelope & Extract<IndexCohortDiagnosis, {
|
|
97
|
+
_tag: 'outliers';
|
|
98
|
+
}>);
|
|
99
|
+
/**
|
|
100
|
+
* The enumerated not-indexed list must cover at least this share of the count
|
|
101
|
+
* gscdump reports. Below it the list is a sample, and every rate built on it
|
|
102
|
+
* would treat the unlisted URLs as absent (nuxtseo.com #1306).
|
|
103
|
+
*/
|
|
104
|
+
export declare const COHORT_MIN_ENUMERATED_SHARE = 0.9;
|
|
105
|
+
/** From this share of pages not indexed, no family can be an outlier: the failure covers the whole Site. */
|
|
106
|
+
export declare const SITE_WIDE_NOT_INDEXED_RATE = 0.5;
|
|
107
|
+
/** Below this many pages, a cohort's interval is too wide for any claim. */
|
|
108
|
+
export declare const COHORT_MIN_CELL_SIZE = 12;
|
|
109
|
+
/** Message for the `sampled-index-state` reason, shared by every surface. */
|
|
110
|
+
export declare function sampledIndexStateMessage(enumerated: number, reported: number): string;
|
|
111
|
+
/**
|
|
112
|
+
* Wilson score interval for a binomial proportion. The normal approximation
|
|
113
|
+
* gives bounds outside 0 to 1 on small, skewed cohorts and zero width at p=0.
|
|
114
|
+
*/
|
|
115
|
+
export declare function wilsonInterval(successes: number, total: number, z: number): {
|
|
116
|
+
lower: number;
|
|
117
|
+
upper: number;
|
|
118
|
+
};
|
|
119
|
+
/** True when the path belongs to the Index cohort the filter names. */
|
|
120
|
+
export declare function belongsToIndexCohort(path: string, filter: IndexCohortFilter): boolean;
|
|
121
|
+
export type IndexCohortFilterParse = {
|
|
122
|
+
_tag: 'ok';
|
|
123
|
+
filter: IndexCohortFilter;
|
|
124
|
+
} | {
|
|
125
|
+
_tag: 'invalid';
|
|
126
|
+
value: unknown;
|
|
127
|
+
};
|
|
128
|
+
/** Parse a `dimension:key` query value, such as `section:/docs` or `lifecycle:versioned`. */
|
|
129
|
+
export declare function parseIndexCohortFilter(value: unknown): IndexCohortFilterParse;
|
|
130
|
+
/** The `dimension:key` query value of a cell, the inverse of `parseIndexCohortFilter`. */
|
|
131
|
+
export declare function indexCohortFilterValue(cell: Pick<IndexCohortCell, 'dimension' | 'key'>): string;
|
|
132
|
+
export interface BuildIndexCohortInput {
|
|
133
|
+
pages: ReadonlyArray<IndexCohortPage>;
|
|
134
|
+
excludedFragments: number;
|
|
135
|
+
notCrawled: number;
|
|
136
|
+
}
|
|
137
|
+
/** Partition the analysed pages and keep only the cohorts Google treats worse than the rest of the Site. */
|
|
138
|
+
export declare function buildIndexCohortDiagnosis(input: BuildIndexCohortInput): IndexCohortDiagnosis;
|
|
139
|
+
/**
|
|
140
|
+
* One section's raw not-indexed tally, with no significance test. `established`
|
|
141
|
+
* rides on every row, so a consumer cannot show the ranking without also
|
|
142
|
+
* showing which rows passed the test.
|
|
143
|
+
*/
|
|
144
|
+
export interface IndexCohortSectionRank {
|
|
145
|
+
/** Path prefix at depth 1 or 2: the same keys the tested cohorts use. */
|
|
146
|
+
key: string;
|
|
147
|
+
total: number;
|
|
148
|
+
notIndexed: number;
|
|
149
|
+
/** Not-indexed share within the section. A point estimate, not a finding. */
|
|
150
|
+
rate: number;
|
|
151
|
+
/** This section also passed the corrected significance test in the same run. */
|
|
152
|
+
established: boolean;
|
|
153
|
+
/** Too few pages for any test (`COHORT_MIN_CELL_SIZE`). */
|
|
154
|
+
belowMinimumSample: boolean;
|
|
155
|
+
}
|
|
156
|
+
/** Every section ranked by not-indexed rate, worst first. Sections that span every page are dropped. */
|
|
157
|
+
export declare function rankIndexCohortSections(pages: ReadonlyArray<IndexCohortPage>, established?: ReadonlyArray<IndexCohortCell>): IndexCohortSectionRank[];
|
|
158
|
+
/** The lead derived from an Index cohort diagnosis. `none` says why there is no lead. */
|
|
159
|
+
export type IndexCohortLead = {
|
|
160
|
+
_tag: 'none';
|
|
161
|
+
reason: 'no-evidence' | 'no-outlier' | 'site-wide';
|
|
162
|
+
} | {
|
|
163
|
+
_tag: 'lead';
|
|
164
|
+
/** The noun and the number. */
|
|
165
|
+
title: string;
|
|
166
|
+
/** The comparison that makes the title a diagnosis. */
|
|
167
|
+
detail: string;
|
|
168
|
+
actionLabel: string;
|
|
169
|
+
cell: IndexCohortCell;
|
|
170
|
+
};
|
|
171
|
+
/**
|
|
172
|
+
* Promote the worst cohort to the primary diagnosis. A located claim is more
|
|
173
|
+
* actionable than a reason-led one. When no cohort separates, or most of the
|
|
174
|
+
* Site is not indexed, there is no lead.
|
|
175
|
+
*/
|
|
176
|
+
export declare function selectIndexCohortLead(diagnosis: {
|
|
177
|
+
_tag: 'no-evidence';
|
|
178
|
+
} | Exclude<IndexCohortDiagnosis, {
|
|
179
|
+
_tag: 'no-evidence';
|
|
180
|
+
}>): IndexCohortLead;
|
|
181
|
+
/** One inspected URL row, as the hosted URLs read returns it. */
|
|
182
|
+
export type IndexCohortUrlRow = Pick<GscdumpIndexingUrl, 'url' | 'verdict'> & Partial<Pick<GscdumpIndexingUrl, 'sitemaps'>>;
|
|
183
|
+
export interface IndexCohortSource<Row extends IndexCohortUrlRow = IndexCohortUrlRow> {
|
|
184
|
+
urls: ReadonlyArray<Row>;
|
|
185
|
+
/** The Site URL the rows belong to, used to resolve relative rows. */
|
|
186
|
+
siteUrl?: string | null;
|
|
187
|
+
/**
|
|
188
|
+
* The not-indexed count gscdump reports for the Site: the diagnostics
|
|
189
|
+
* `not_indexed` issue. 0 when the issue is absent.
|
|
190
|
+
*/
|
|
191
|
+
reportedNotIndexed: number;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Build the Index cohorts response from a complete set of inspected URLs. No
|
|
195
|
+
* Crawl joins it, so `notCrawled` is 0 and `crawlSettingsId` is null. Below
|
|
196
|
+
* `COHORT_MIN_ENUMERATED_SHARE` of the reported not-indexed count, the builder
|
|
197
|
+
* refuses with `sampled-index-state` rather than rank sections.
|
|
198
|
+
*/
|
|
199
|
+
export declare function buildIndexCohortsFromIndexingUrls(source: IndexCohortSource, asOf?: string | null): IndexCohortsResponse;
|
|
200
|
+
/** The not-indexed rows that belong to one Index cohort. */
|
|
201
|
+
export declare function selectIndexCohortUrls<Row extends IndexCohortUrlRow>(urls: ReadonlyArray<Row>, filter: IndexCohortFilter, siteUrl?: string | null): Row[];
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
const COHORT_MIN_ENUMERATED_SHARE = .9;
|
|
2
|
+
const SITE_WIDE_NOT_INDEXED_RATE = .5;
|
|
3
|
+
const COHORT_MIN_CELL_SIZE = 12;
|
|
4
|
+
const COHORT_FAMILY_ALPHA = .05;
|
|
5
|
+
const VERSION_SEGMENT = /\/v\d+\/.+/;
|
|
6
|
+
const SECTION_KEY = /^\/(?:[^/?#]+(?:\/[^/?#]+)?)?$/;
|
|
7
|
+
function sampledIndexStateMessage(enumerated, reported) {
|
|
8
|
+
return `Google index state is a sample: ${enumerated.toLocaleString("en-US")} of ${reported.toLocaleString("en-US")} not-indexed URLs. Family rates would be invented. Read the indexing summary instead.`;
|
|
9
|
+
}
|
|
10
|
+
function wilsonInterval(successes, total, z) {
|
|
11
|
+
if (total <= 0) return {
|
|
12
|
+
lower: 0,
|
|
13
|
+
upper: 1
|
|
14
|
+
};
|
|
15
|
+
const p = successes / total;
|
|
16
|
+
const z2 = z * z;
|
|
17
|
+
const denominator = 1 + z2 / total;
|
|
18
|
+
const centre = p + z2 / (2 * total);
|
|
19
|
+
const spread = z * Math.sqrt(p * (1 - p) / total + z2 / (4 * total * total));
|
|
20
|
+
return {
|
|
21
|
+
lower: Math.max(0, (centre - spread) / denominator),
|
|
22
|
+
upper: Math.min(1, (centre + spread) / denominator)
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function normalQuantile(p) {
|
|
26
|
+
if (p <= 0) return Number.NEGATIVE_INFINITY;
|
|
27
|
+
if (p >= 1) return Number.POSITIVE_INFINITY;
|
|
28
|
+
const a = [
|
|
29
|
+
-39.69683028665376,
|
|
30
|
+
220.9460984245205,
|
|
31
|
+
-275.9285104469687,
|
|
32
|
+
138.357751867269,
|
|
33
|
+
-30.66479806614716,
|
|
34
|
+
2.506628277459239
|
|
35
|
+
];
|
|
36
|
+
const b = [
|
|
37
|
+
-54.47609879822406,
|
|
38
|
+
161.5858368580409,
|
|
39
|
+
-155.6989798598866,
|
|
40
|
+
66.80131188771972,
|
|
41
|
+
-13.28068155288572
|
|
42
|
+
];
|
|
43
|
+
const c = [
|
|
44
|
+
-.007784894002430293,
|
|
45
|
+
-.3223964580411365,
|
|
46
|
+
-2.400758277161838,
|
|
47
|
+
-2.549732539343734,
|
|
48
|
+
4.374664141464968,
|
|
49
|
+
2.938163982698783
|
|
50
|
+
];
|
|
51
|
+
const d = [
|
|
52
|
+
.007784695709041462,
|
|
53
|
+
.3224671290700398,
|
|
54
|
+
2.445134137142996,
|
|
55
|
+
3.754408661907416
|
|
56
|
+
];
|
|
57
|
+
const pLow = .02425;
|
|
58
|
+
const pHigh = .97575;
|
|
59
|
+
if (p < pLow) {
|
|
60
|
+
const q = Math.sqrt(-2 * Math.log(p));
|
|
61
|
+
return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
|
|
62
|
+
}
|
|
63
|
+
if (p > pHigh) {
|
|
64
|
+
const q = Math.sqrt(-2 * Math.log(1 - p));
|
|
65
|
+
return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
|
|
66
|
+
}
|
|
67
|
+
const q = p - .5;
|
|
68
|
+
const r = q * q;
|
|
69
|
+
return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q / (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1);
|
|
70
|
+
}
|
|
71
|
+
function criticalValue(tested) {
|
|
72
|
+
const alpha = COHORT_FAMILY_ALPHA / Math.max(1, tested);
|
|
73
|
+
return Math.abs(normalQuantile(alpha / 2));
|
|
74
|
+
}
|
|
75
|
+
function sectionKeys(path) {
|
|
76
|
+
const segments = path.split("/").filter(Boolean);
|
|
77
|
+
if (segments.length === 0) return ["/"];
|
|
78
|
+
const depth1 = `/${segments[0]}`;
|
|
79
|
+
if (segments.length === 1) return [depth1];
|
|
80
|
+
return [depth1, `/${segments.slice(0, 2).join("/")}`];
|
|
81
|
+
}
|
|
82
|
+
function lifecycleKey(path) {
|
|
83
|
+
return VERSION_SEGMENT.test(path) ? "versioned" : "evergreen";
|
|
84
|
+
}
|
|
85
|
+
function belongsToIndexCohort(path, filter) {
|
|
86
|
+
return filter.dimension === "section" ? sectionKeys(path).includes(filter.key) : lifecycleKey(path) === filter.key;
|
|
87
|
+
}
|
|
88
|
+
function parseIndexCohortFilter(value) {
|
|
89
|
+
if (typeof value !== "string") return {
|
|
90
|
+
_tag: "invalid",
|
|
91
|
+
value
|
|
92
|
+
};
|
|
93
|
+
const colon = value.indexOf(":");
|
|
94
|
+
const dimension = value.slice(0, colon);
|
|
95
|
+
const key = value.slice(colon + 1);
|
|
96
|
+
if (colon > 0 && dimension === "section" && SECTION_KEY.test(key)) return {
|
|
97
|
+
_tag: "ok",
|
|
98
|
+
filter: {
|
|
99
|
+
dimension,
|
|
100
|
+
key
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
if (colon > 0 && dimension === "lifecycle" && (key === "versioned" || key === "evergreen")) return {
|
|
104
|
+
_tag: "ok",
|
|
105
|
+
filter: {
|
|
106
|
+
dimension,
|
|
107
|
+
key
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
return {
|
|
111
|
+
_tag: "invalid",
|
|
112
|
+
value
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function indexCohortFilterValue(cell) {
|
|
116
|
+
return `${cell.dimension}:${cell.key}`;
|
|
117
|
+
}
|
|
118
|
+
const LIFECYCLE_LABEL = {
|
|
119
|
+
versioned: "Version-pinned paths",
|
|
120
|
+
evergreen: "Unversioned paths"
|
|
121
|
+
};
|
|
122
|
+
function tally(pages, keysOf) {
|
|
123
|
+
const out = /* @__PURE__ */ new Map();
|
|
124
|
+
for (const page of pages) for (const key of keysOf(page)) {
|
|
125
|
+
const current = out.get(key) ?? {
|
|
126
|
+
total: 0,
|
|
127
|
+
notIndexed: 0,
|
|
128
|
+
notIndexedPaths: []
|
|
129
|
+
};
|
|
130
|
+
current.total += 1;
|
|
131
|
+
if (!page.indexed) {
|
|
132
|
+
current.notIndexed += 1;
|
|
133
|
+
current.notIndexedPaths.push(page.path);
|
|
134
|
+
}
|
|
135
|
+
out.set(key, current);
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
function buildIndexCohortDiagnosis(input) {
|
|
140
|
+
const { pages, excludedFragments, notCrawled } = input;
|
|
141
|
+
if (pages.length === 0) return {
|
|
142
|
+
_tag: "no-evidence",
|
|
143
|
+
reason: "no-inspection-join"
|
|
144
|
+
};
|
|
145
|
+
const notIndexed = pages.reduce((sum, page) => sum + (page.indexed ? 0 : 1), 0);
|
|
146
|
+
const baseline = {
|
|
147
|
+
total: pages.length,
|
|
148
|
+
notIndexed,
|
|
149
|
+
rate: notIndexed / pages.length
|
|
150
|
+
};
|
|
151
|
+
const coverage = {
|
|
152
|
+
analysed: pages.length,
|
|
153
|
+
excludedFragments,
|
|
154
|
+
notCrawled
|
|
155
|
+
};
|
|
156
|
+
const candidates = [{
|
|
157
|
+
dimension: "section",
|
|
158
|
+
tallies: tally(pages, (page) => sectionKeys(page.path)),
|
|
159
|
+
label: (key) => key
|
|
160
|
+
}, {
|
|
161
|
+
dimension: "lifecycle",
|
|
162
|
+
tallies: tally(pages, (page) => [lifecycleKey(page.path)]),
|
|
163
|
+
label: (key) => LIFECYCLE_LABEL[key] ?? key
|
|
164
|
+
}].flatMap(({ dimension, tallies, label }) => [...tallies.entries()].filter(([, cell]) => cell.total >= 12 && cell.total < pages.length).map(([key, cell]) => ({
|
|
165
|
+
dimension,
|
|
166
|
+
key,
|
|
167
|
+
label: label(key),
|
|
168
|
+
cell
|
|
169
|
+
})));
|
|
170
|
+
const tested = candidates.length;
|
|
171
|
+
const z = criticalValue(tested);
|
|
172
|
+
if (tested === 0) return {
|
|
173
|
+
_tag: "uniform",
|
|
174
|
+
baseline,
|
|
175
|
+
coverage,
|
|
176
|
+
z,
|
|
177
|
+
tested
|
|
178
|
+
};
|
|
179
|
+
const survivors = [];
|
|
180
|
+
for (const { dimension, key, label, cell } of candidates) {
|
|
181
|
+
const complementTotal = baseline.total - cell.total;
|
|
182
|
+
const complementNotIndexed = baseline.notIndexed - cell.notIndexed;
|
|
183
|
+
const complementRate = complementTotal > 0 ? complementNotIndexed / complementTotal : 0;
|
|
184
|
+
const { lower, upper } = wilsonInterval(cell.notIndexed, cell.total, z);
|
|
185
|
+
if (complementTotal < 12) continue;
|
|
186
|
+
if (lower <= complementRate) continue;
|
|
187
|
+
survivors.push({
|
|
188
|
+
notIndexedPaths: cell.notIndexedPaths,
|
|
189
|
+
cell: {
|
|
190
|
+
dimension,
|
|
191
|
+
key,
|
|
192
|
+
label,
|
|
193
|
+
pathPrefix: dimension === "section" ? key : null,
|
|
194
|
+
total: cell.total,
|
|
195
|
+
notIndexed: cell.notIndexed,
|
|
196
|
+
rate: cell.notIndexed / cell.total,
|
|
197
|
+
complementRate,
|
|
198
|
+
complementTotal,
|
|
199
|
+
ciLower: lower,
|
|
200
|
+
ciUpper: upper,
|
|
201
|
+
overlapNotIndexed: 0
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
if (survivors.length === 0) return {
|
|
206
|
+
_tag: "uniform",
|
|
207
|
+
baseline,
|
|
208
|
+
coverage,
|
|
209
|
+
z,
|
|
210
|
+
tested
|
|
211
|
+
};
|
|
212
|
+
survivors.sort((left, right) => right.cell.rate - right.cell.complementRate - (left.cell.rate - left.cell.complementRate) || right.cell.notIndexed - left.cell.notIndexed);
|
|
213
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
214
|
+
return {
|
|
215
|
+
_tag: "outliers",
|
|
216
|
+
baseline,
|
|
217
|
+
coverage,
|
|
218
|
+
z,
|
|
219
|
+
tested,
|
|
220
|
+
cells: survivors.map(({ cell, notIndexedPaths }) => {
|
|
221
|
+
const overlapNotIndexed = notIndexedPaths.reduce((sum, path) => sum + (claimed.has(path) ? 1 : 0), 0);
|
|
222
|
+
for (const path of notIndexedPaths) claimed.add(path);
|
|
223
|
+
return {
|
|
224
|
+
...cell,
|
|
225
|
+
overlapNotIndexed
|
|
226
|
+
};
|
|
227
|
+
})
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
function rankIndexCohortSections(pages, established = []) {
|
|
231
|
+
const establishedKeys = new Set(established.filter((cell) => cell.dimension === "section").map((cell) => cell.key));
|
|
232
|
+
return [...tally(pages, (page) => sectionKeys(page.path)).entries()].filter(([, cell]) => cell.total < pages.length).map(([key, cell]) => ({
|
|
233
|
+
key,
|
|
234
|
+
total: cell.total,
|
|
235
|
+
notIndexed: cell.notIndexed,
|
|
236
|
+
rate: cell.notIndexed / cell.total,
|
|
237
|
+
established: establishedKeys.has(key),
|
|
238
|
+
belowMinimumSample: cell.total < 12
|
|
239
|
+
})).sort((left, right) => right.rate - left.rate || right.notIndexed - left.notIndexed || left.key.localeCompare(right.key));
|
|
240
|
+
}
|
|
241
|
+
function cohortSubject(cell) {
|
|
242
|
+
return cell.dimension === "section" ? `pages under ${cell.key}` : "version-pinned pages";
|
|
243
|
+
}
|
|
244
|
+
function asPercent(value) {
|
|
245
|
+
return `${Math.round(value * 100)}%`;
|
|
246
|
+
}
|
|
247
|
+
function selectIndexCohortLead(diagnosis) {
|
|
248
|
+
if (diagnosis._tag === "no-evidence") return {
|
|
249
|
+
_tag: "none",
|
|
250
|
+
reason: "no-evidence"
|
|
251
|
+
};
|
|
252
|
+
if (diagnosis._tag === "uniform") return {
|
|
253
|
+
_tag: "none",
|
|
254
|
+
reason: "no-outlier"
|
|
255
|
+
};
|
|
256
|
+
if (diagnosis.baseline.rate >= .5) return {
|
|
257
|
+
_tag: "none",
|
|
258
|
+
reason: "site-wide"
|
|
259
|
+
};
|
|
260
|
+
const cell = diagnosis.cells[0];
|
|
261
|
+
if (!cell) return {
|
|
262
|
+
_tag: "none",
|
|
263
|
+
reason: "no-outlier"
|
|
264
|
+
};
|
|
265
|
+
return {
|
|
266
|
+
_tag: "lead",
|
|
267
|
+
title: `${cell.notIndexed.toLocaleString("en-US")} of ${cell.total.toLocaleString("en-US")} ${cohortSubject(cell)} are missing from Google's index.`,
|
|
268
|
+
detail: `That is ${asPercent(cell.rate)} not indexed, against ${asPercent(cell.complementRate)} across the rest of the site.`,
|
|
269
|
+
actionLabel: `Review ${cell.notIndexed.toLocaleString("en-US")} ${cell.notIndexed === 1 ? "URL" : "URLs"}`,
|
|
270
|
+
cell
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
function siteOrigin(siteUrl) {
|
|
274
|
+
const site = siteUrl?.trim();
|
|
275
|
+
if (!site) return void 0;
|
|
276
|
+
if (site.startsWith("sc-domain:")) return `https://${site.slice(10)}`;
|
|
277
|
+
return site.includes("://") ? site : `https://${site}`;
|
|
278
|
+
}
|
|
279
|
+
function parseRow(row, siteUrl) {
|
|
280
|
+
const candidate = row.url?.trim();
|
|
281
|
+
if (!candidate) return { _tag: "unparsable" };
|
|
282
|
+
const base = siteOrigin(siteUrl);
|
|
283
|
+
const parsed = URL.parse(candidate) ?? (base ? URL.parse(candidate, base) : null);
|
|
284
|
+
if (!parsed || parsed.protocol !== "https:" && parsed.protocol !== "http:") return { _tag: "unparsable" };
|
|
285
|
+
if (parsed.hash) return { _tag: "fragment" };
|
|
286
|
+
return {
|
|
287
|
+
_tag: "page",
|
|
288
|
+
href: parsed.href,
|
|
289
|
+
page: {
|
|
290
|
+
path: parsed.pathname,
|
|
291
|
+
indexed: row.verdict === "PASS",
|
|
292
|
+
inSitemap: Array.isArray(row.sitemaps) ? row.sitemaps.length > 0 : false
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function buildIndexCohortsFromIndexingUrls(source, asOf = null) {
|
|
297
|
+
const pages = [];
|
|
298
|
+
const notIndexedUrls = /* @__PURE__ */ new Set();
|
|
299
|
+
let excludedFragments = 0;
|
|
300
|
+
for (const row of source.urls) {
|
|
301
|
+
const parsed = parseRow(row, source.siteUrl);
|
|
302
|
+
if (parsed._tag === "page") {
|
|
303
|
+
pages.push(parsed.page);
|
|
304
|
+
if (!parsed.page.indexed) notIndexedUrls.add(parsed.href);
|
|
305
|
+
} else if (parsed._tag === "fragment") excludedFragments += 1;
|
|
306
|
+
}
|
|
307
|
+
const enumerated = notIndexedUrls.size;
|
|
308
|
+
const reported = source.reportedNotIndexed;
|
|
309
|
+
if (reported > 0 && enumerated / reported < .9) return {
|
|
310
|
+
_tag: "no-evidence",
|
|
311
|
+
crawlSettingsId: null,
|
|
312
|
+
asOf,
|
|
313
|
+
reason: "sampled-index-state",
|
|
314
|
+
sample: {
|
|
315
|
+
enumerated,
|
|
316
|
+
reported
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
const diagnosis = buildIndexCohortDiagnosis({
|
|
320
|
+
pages,
|
|
321
|
+
excludedFragments,
|
|
322
|
+
notCrawled: 0
|
|
323
|
+
});
|
|
324
|
+
if (diagnosis._tag === "no-evidence") return {
|
|
325
|
+
_tag: "no-evidence",
|
|
326
|
+
crawlSettingsId: null,
|
|
327
|
+
asOf,
|
|
328
|
+
reason: diagnosis.reason,
|
|
329
|
+
sample: null
|
|
330
|
+
};
|
|
331
|
+
return {
|
|
332
|
+
...diagnosis,
|
|
333
|
+
crawlSettingsId: null,
|
|
334
|
+
asOf
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
function selectIndexCohortUrls(urls, filter, siteUrl) {
|
|
338
|
+
return urls.filter((row) => {
|
|
339
|
+
const parsed = parseRow(row, siteUrl);
|
|
340
|
+
return parsed._tag === "page" && !parsed.page.indexed && belongsToIndexCohort(parsed.page.path, filter);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
export { COHORT_MIN_CELL_SIZE, COHORT_MIN_ENUMERATED_SHARE, SITE_WIDE_NOT_INDEXED_RATE, belongsToIndexCohort, buildIndexCohortDiagnosis, buildIndexCohortsFromIndexingUrls, indexCohortFilterValue, parseIndexCohortFilter, rankIndexCohortSections, sampledIndexStateMessage, selectIndexCohortLead, selectIndexCohortUrls, wilsonInterval };
|