@gscdump/sdk 7.12.1 → 7.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,354 @@
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
+ const complement = wilsonInterval(complementNotIndexed, complementTotal, z);
187
+ const rate = cell.notIndexed / cell.total;
188
+ if (rate - complementRate - Math.hypot(rate - lower, complement.upper - complementRate) <= 0) continue;
189
+ survivors.push({
190
+ notIndexedPaths: cell.notIndexedPaths,
191
+ cell: {
192
+ dimension,
193
+ key,
194
+ label,
195
+ pathPrefix: dimension === "section" ? key : null,
196
+ total: cell.total,
197
+ notIndexed: cell.notIndexed,
198
+ rate,
199
+ complementRate,
200
+ complementTotal,
201
+ ciLower: lower,
202
+ ciUpper: upper,
203
+ overlapNotIndexed: 0
204
+ }
205
+ });
206
+ }
207
+ if (survivors.length === 0) return {
208
+ _tag: "uniform",
209
+ baseline,
210
+ coverage,
211
+ z,
212
+ tested
213
+ };
214
+ survivors.sort((left, right) => right.cell.rate - right.cell.complementRate - (left.cell.rate - left.cell.complementRate) || right.cell.notIndexed - left.cell.notIndexed);
215
+ const claimed = /* @__PURE__ */ new Set();
216
+ return {
217
+ _tag: "outliers",
218
+ baseline,
219
+ coverage,
220
+ z,
221
+ tested,
222
+ cells: survivors.map(({ cell, notIndexedPaths }) => {
223
+ const overlapNotIndexed = notIndexedPaths.reduce((sum, path) => sum + (claimed.has(path) ? 1 : 0), 0);
224
+ for (const path of notIndexedPaths) claimed.add(path);
225
+ return {
226
+ ...cell,
227
+ overlapNotIndexed
228
+ };
229
+ })
230
+ };
231
+ }
232
+ function rankIndexCohortSections(pages, established = []) {
233
+ const establishedKeys = new Set(established.filter((cell) => cell.dimension === "section").map((cell) => cell.key));
234
+ return [...tally(pages, (page) => sectionKeys(page.path)).entries()].filter(([, cell]) => cell.total < pages.length).map(([key, cell]) => ({
235
+ key,
236
+ total: cell.total,
237
+ notIndexed: cell.notIndexed,
238
+ rate: cell.notIndexed / cell.total,
239
+ established: establishedKeys.has(key),
240
+ belowMinimumSample: cell.total < 12
241
+ })).sort((left, right) => right.rate - left.rate || right.notIndexed - left.notIndexed || left.key.localeCompare(right.key));
242
+ }
243
+ const LIFECYCLE_SUBJECT = {
244
+ versioned: "version-pinned pages",
245
+ evergreen: "unversioned pages"
246
+ };
247
+ function cohortSubject(cell) {
248
+ return cell.dimension === "section" ? `pages under ${cell.key}` : LIFECYCLE_SUBJECT[cell.key] ?? cell.label.toLowerCase();
249
+ }
250
+ function asPercent(value) {
251
+ return `${Math.round(value * 100)}%`;
252
+ }
253
+ function selectIndexCohortLead(diagnosis) {
254
+ if (diagnosis._tag === "no-evidence") return {
255
+ _tag: "none",
256
+ reason: "no-evidence"
257
+ };
258
+ if (diagnosis._tag === "uniform") return {
259
+ _tag: "none",
260
+ reason: "no-outlier"
261
+ };
262
+ if (diagnosis.baseline.rate >= .5) return {
263
+ _tag: "none",
264
+ reason: "site-wide"
265
+ };
266
+ const cell = diagnosis.cells[0];
267
+ if (!cell) return {
268
+ _tag: "none",
269
+ reason: "no-outlier"
270
+ };
271
+ return {
272
+ _tag: "lead",
273
+ title: `${cell.notIndexed.toLocaleString("en-US")} of ${cell.total.toLocaleString("en-US")} ${cohortSubject(cell)} are missing from Google's index.`,
274
+ detail: `That is ${asPercent(cell.rate)} not indexed, against ${asPercent(cell.complementRate)} across the rest of the site.`,
275
+ actionLabel: `Review ${cell.notIndexed.toLocaleString("en-US")} ${cell.notIndexed === 1 ? "URL" : "URLs"}`,
276
+ cell
277
+ };
278
+ }
279
+ function siteOrigin(siteUrl) {
280
+ const site = siteUrl?.trim();
281
+ if (!site) return void 0;
282
+ if (site.startsWith("sc-domain:")) return `https://${site.slice(10)}`;
283
+ return site.includes("://") ? site : `https://${site}`;
284
+ }
285
+ function parseRow(row, siteUrl) {
286
+ const candidate = row.url?.trim();
287
+ if (!candidate) return { _tag: "unparsable" };
288
+ const base = siteOrigin(siteUrl);
289
+ const parsed = URL.parse(candidate) ?? (base ? URL.parse(candidate, base) : null);
290
+ if (!parsed || parsed.protocol !== "https:" && parsed.protocol !== "http:") return { _tag: "unparsable" };
291
+ if (parsed.hash) return { _tag: "fragment" };
292
+ return {
293
+ _tag: "page",
294
+ href: parsed.href,
295
+ page: {
296
+ path: parsed.pathname,
297
+ indexed: row.verdict === "PASS",
298
+ inSitemap: Array.isArray(row.sitemaps) ? row.sitemaps.length > 0 : false
299
+ }
300
+ };
301
+ }
302
+ function uniqueRows(urls, siteUrl) {
303
+ const seen = /* @__PURE__ */ new Set();
304
+ return urls.flatMap((row) => {
305
+ const parsed = parseRow(row, siteUrl);
306
+ if (parsed._tag === "page") {
307
+ if (seen.has(parsed.href)) return [];
308
+ seen.add(parsed.href);
309
+ }
310
+ return [{
311
+ row,
312
+ parsed
313
+ }];
314
+ });
315
+ }
316
+ function buildIndexCohortsFromIndexingUrls(source, asOf = null) {
317
+ const pages = [];
318
+ let excludedFragments = 0;
319
+ for (const { parsed } of uniqueRows(source.urls, source.siteUrl)) if (parsed._tag === "page") pages.push(parsed.page);
320
+ else if (parsed._tag === "fragment") excludedFragments += 1;
321
+ const enumerated = pages.reduce((sum, page) => sum + (page.indexed ? 0 : 1), 0);
322
+ const reported = source.reportedNotIndexed;
323
+ if (reported > 0 && enumerated / reported < .9) return {
324
+ _tag: "no-evidence",
325
+ crawlSettingsId: null,
326
+ asOf,
327
+ reason: "sampled-index-state",
328
+ sample: {
329
+ enumerated,
330
+ reported
331
+ }
332
+ };
333
+ const diagnosis = buildIndexCohortDiagnosis({
334
+ pages,
335
+ excludedFragments,
336
+ notCrawled: 0
337
+ });
338
+ if (diagnosis._tag === "no-evidence") return {
339
+ _tag: "no-evidence",
340
+ crawlSettingsId: null,
341
+ asOf,
342
+ reason: diagnosis.reason,
343
+ sample: null
344
+ };
345
+ return {
346
+ ...diagnosis,
347
+ crawlSettingsId: null,
348
+ asOf
349
+ };
350
+ }
351
+ function selectIndexCohortUrls(urls, filter, siteUrl) {
352
+ return uniqueRows(urls, siteUrl).filter(({ parsed }) => parsed._tag === "page" && !parsed.page.indexed && belongsToIndexCohort(parsed.page.path, filter)).map(({ row }) => row);
353
+ }
354
+ export { COHORT_MIN_CELL_SIZE, COHORT_MIN_ENUMERATED_SHARE, SITE_WIDE_NOT_INDEXED_RATE, belongsToIndexCohort, buildIndexCohortDiagnosis, buildIndexCohortsFromIndexingUrls, indexCohortFilterValue, parseIndexCohortFilter, rankIndexCohortSections, sampledIndexStateMessage, selectIndexCohortLead, selectIndexCohortUrls, wilsonInterval };