@digestseo/mcp-gsc 0.4.1 → 0.4.2
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/CHANGELOG.md +7 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/google.ts +10 -0
- package/src/index.ts +34 -19
- package/src/quick-wins-schema.ts +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.2] - 2026-09-21
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Cannibalization totals and impression shares now expose their query/page-row aggregation scope explicitly so clients do not mistake summed page impressions for a true query-level Search Console total.
|
|
14
|
+
- Discover Search Analytics calls now reject unsupported query grouping/filtering before reaching Google, and query-centric/comparison tools no longer advertise Discover where their contracts depend on query or average-position data that Discover does not provide.
|
|
15
|
+
- Quick-win guidance now treats Search Console position as an aggregate average-position metric rather than a literal current rank, and metadata no longer implies CTR is an eligibility filter when the implementation does not filter on CTR.
|
|
16
|
+
|
|
10
17
|
## [0.4.1] - 2026-09-21
|
|
11
18
|
|
|
12
19
|
### Changed
|
package/README.md
CHANGED
|
@@ -78,7 +78,7 @@ By default (`GSC_ACCESS_MODE=readwrite`), this server exposes 21 tools. Read-onl
|
|
|
78
78
|
| **`urls.inspect`** | Read | Google's URL Inspection report for a single page. |
|
|
79
79
|
| **`urls.inspect_many`** | Read | Inspect up to 10 URLs sequentially in one call. Each URL still consumes one Google URL Inspection request and one unit of the server's shared inspection safety budget. |
|
|
80
80
|
| **`sitemaps.list`** / **`sitemaps.get`** | Read | List submitted sitemaps or retrieve one sitemap's details. |
|
|
81
|
-
| **`insights.quick_wins`** / **`insights.cannibalization`** / **`insights.content_decay`** | Read | Surface
|
|
81
|
+
| **`insights.quick_wins`** / **`insights.cannibalization`** / **`insights.content_decay`** | Read | Surface average-position opportunity rows, query/page overlap, and evidence-ranked click declines. Quick-win candidates are observed query/page rows selected by aggregate Search Console average position, not proof of a stable current rank, and CTR is context rather than an eligibility filter. Cannibalization candidate totals/shares are scoped to observed query/page rows rather than true query-level property totals; content-decay results distinguish likely decay from weak evidence and improving visibility with click volatility. |
|
|
82
82
|
| **`indexing.list_pages`** / **`analytics.compare`** | Read | Analyze pages receiving Search Console impressions and compare two periods, optionally using the same search type and query/page/country/device/search-appearance filters for both periods. `indexing.list_pages` is performance data, not index coverage: a missing URL may still be indexed; use `urls.inspect` / `urls.inspect_many` for URL-level index status. |
|
|
83
83
|
| **`reports.weekly_digest`** | Read | Generate a plain-language seven-day performance report with movers, top pages, and one recommended action. |
|
|
84
84
|
| **`sites.add`** / **`sites.delete`** | Write | Add or remove a Search Console property. |
|
|
@@ -98,7 +98,7 @@ Once connected, ask your assistant things like:
|
|
|
98
98
|
- *"What are my top 20 queries by clicks in the last 28 days?"*
|
|
99
99
|
- *"Compare impressions for example.com this month vs last month — which pages dropped?"*
|
|
100
100
|
- *"Is `https://example.com/pricing` indexed? When was it last crawled?"*
|
|
101
|
-
- *"Which
|
|
101
|
+
- *"Which high-impression query/page rows have an average position between 5 and 15? Show me those optimization opportunities."*
|
|
102
102
|
- *"Give me a weekly digest for `sc-domain:example.com` ending today."*
|
|
103
103
|
- *"Do any of my sitemaps have errors or warnings?"*
|
|
104
104
|
- *"Split my clicks into brand vs non-brand using a regex on the query."*
|
package/package.json
CHANGED
package/src/google.ts
CHANGED
|
@@ -247,6 +247,8 @@ export function assertSearchAnalyticsQueryCompatible(body: SearchAnalyticsQuery)
|
|
|
247
247
|
const filters = body.dimensionFilterGroups?.flatMap((group) => group.filters) ?? [];
|
|
248
248
|
const hasPageGroupingOrFilter =
|
|
249
249
|
body.dimensions.includes('page') || filters.some((filter) => filter.dimension === 'page');
|
|
250
|
+
const hasQueryGroupingOrFilter =
|
|
251
|
+
body.dimensions.includes('query') || filters.some((filter) => filter.dimension === 'query');
|
|
250
252
|
|
|
251
253
|
for (const filter of filters) {
|
|
252
254
|
if (filter.expression.length > 4096) {
|
|
@@ -267,6 +269,12 @@ export function assertSearchAnalyticsQueryCompatible(body: SearchAnalyticsQuery)
|
|
|
267
269
|
}
|
|
268
270
|
}
|
|
269
271
|
|
|
272
|
+
if (body.type === 'discover' && hasQueryGroupingOrFilter) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
'Google Discover does not support the query dimension or query filters in Search Analytics.',
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
270
278
|
if (body.aggregationType === 'byNewsShowcasePanel') {
|
|
271
279
|
if (body.type !== 'discover' && body.type !== 'googleNews') {
|
|
272
280
|
throw new Error(
|
|
@@ -720,6 +728,7 @@ export interface CannibalizationResult {
|
|
|
720
728
|
query: string;
|
|
721
729
|
total_clicks: number;
|
|
722
730
|
total_impressions: number;
|
|
731
|
+
aggregation_scope: 'observed_query_page_rows';
|
|
723
732
|
pages: CannibalizationPage[];
|
|
724
733
|
}
|
|
725
734
|
|
|
@@ -763,6 +772,7 @@ export function processCannibalization(
|
|
|
763
772
|
query,
|
|
764
773
|
total_clicks: totalClicks,
|
|
765
774
|
total_impressions: totalImpressions,
|
|
775
|
+
aggregation_scope: 'observed_query_page_rows',
|
|
766
776
|
pages: competingPages.sort(
|
|
767
777
|
(a, b) => b.impressions - a.impressions || a.page.localeCompare(b.page),
|
|
768
778
|
),
|
package/src/index.ts
CHANGED
|
@@ -258,6 +258,7 @@ const RESULT_START_ROW_SCHEMA = z
|
|
|
258
258
|
.describe('Zero-based offset into the deterministically ranked result list.');
|
|
259
259
|
|
|
260
260
|
const QUICK_WIN_OUTPUT_SCHEMA = {
|
|
261
|
+
note: z.string(),
|
|
261
262
|
quick_wins: z.array(
|
|
262
263
|
z.object({
|
|
263
264
|
query: z.string(),
|
|
@@ -297,15 +298,24 @@ const CANNIBALIZATION_OUTPUT_SCHEMA = {
|
|
|
297
298
|
candidates: z.array(
|
|
298
299
|
z.object({
|
|
299
300
|
query: z.string(),
|
|
300
|
-
total_clicks: z.number()
|
|
301
|
-
|
|
301
|
+
total_clicks: z.number().describe(
|
|
302
|
+
'Sum of clicks across the observed query/page Search Analytics rows for this query. This is not a separately queried query-level aggregate.',
|
|
303
|
+
),
|
|
304
|
+
total_impressions: z.number().describe(
|
|
305
|
+
'Sum of impressions across the observed query/page Search Analytics rows for this query. This can exceed the query-level property aggregate when multiple pages appear for the same search, and it can be incomplete when source pagination is truncated.',
|
|
306
|
+
),
|
|
307
|
+
aggregation_scope: z.literal('observed_query_page_rows').describe(
|
|
308
|
+
'Makes explicit that total_clicks, total_impressions, and impression_share are calculated from observed query/page rows rather than true query-level Search Console totals.',
|
|
309
|
+
),
|
|
302
310
|
page_count: z.number().int().nonnegative(),
|
|
303
311
|
pages_truncated: z.boolean(),
|
|
304
312
|
pages: z.array(
|
|
305
313
|
z.object({
|
|
306
314
|
page: z.string(),
|
|
307
315
|
...METRIC_OUTPUT_SCHEMA,
|
|
308
|
-
impression_share: z.number()
|
|
316
|
+
impression_share: z.number().describe(
|
|
317
|
+
'Percentage of the observed query/page impression sum represented by this page; not a share of unique searches or the true query-level property aggregate.',
|
|
318
|
+
),
|
|
309
319
|
}),
|
|
310
320
|
),
|
|
311
321
|
}),
|
|
@@ -543,12 +553,12 @@ const TOOL_CATALOG = [
|
|
|
543
553
|
{
|
|
544
554
|
name: 'insights.quick_wins',
|
|
545
555
|
description:
|
|
546
|
-
'Find
|
|
556
|
+
'Find observed query/page Search Analytics rows with at least the requested impressions whose average position falls in a configurable opportunity range (8-20 by default). Average position is an aggregate metric, not a literal current rank; CTR is context, not an eligibility filter.',
|
|
547
557
|
},
|
|
548
558
|
{
|
|
549
559
|
name: 'insights.cannibalization',
|
|
550
560
|
description:
|
|
551
|
-
'Analyze
|
|
561
|
+
'Analyze query/page Search Analytics for queries surfaced by multiple pages. Candidate totals and impression shares are explicitly scoped to observed query/page rows, not true query-level property totals.',
|
|
552
562
|
},
|
|
553
563
|
{
|
|
554
564
|
name: 'insights.content_decay',
|
|
@@ -871,6 +881,10 @@ class GscMcpRuntime {
|
|
|
871
881
|
'- CTR in the response is a 0–1 fraction. Multiply by 100 for percent.',
|
|
872
882
|
'- Use search_type to query image/video/news/discover indexes',
|
|
873
883
|
' separately from web.',
|
|
884
|
+
'- Google Discover does not support query grouping/filtering or',
|
|
885
|
+
' average position. For Discover, use page/country/device/date/',
|
|
886
|
+
' searchAppearance dimensions and interpret clicks, impressions,',
|
|
887
|
+
' and CTR; query dimensions/filters are rejected before the API call.',
|
|
874
888
|
'- News Showcase panel reporting: set aggregation_type to',
|
|
875
889
|
' "byNewsShowcasePanel", use search_type "discover" or',
|
|
876
890
|
' "googleNews", and include a searchAppearance equals',
|
|
@@ -906,7 +920,7 @@ class GscMcpRuntime {
|
|
|
906
920
|
)
|
|
907
921
|
.default(['query'])
|
|
908
922
|
.describe(
|
|
909
|
-
'Dimensions to group rows by. Pass [] (empty array) to get a single row of true site-level totals.',
|
|
923
|
+
'Dimensions to group rows by. Pass [] (empty array) to get a single row of true site-level totals. Google Discover does not support the query dimension.',
|
|
910
924
|
),
|
|
911
925
|
row_limit: z
|
|
912
926
|
.number()
|
|
@@ -934,7 +948,7 @@ class GscMcpRuntime {
|
|
|
934
948
|
search_type: z
|
|
935
949
|
.enum(['web', 'image', 'video', 'news', 'discover', 'googleNews'])
|
|
936
950
|
.default('web')
|
|
937
|
-
.describe('Which search index to query. Defaults to web.'),
|
|
951
|
+
.describe('Which search index to query. Defaults to web. Google Discover does not support query grouping/filtering or average position.'),
|
|
938
952
|
aggregation_type: z
|
|
939
953
|
.enum(['auto', 'byNewsShowcasePanel', 'byPage', 'byProperty'])
|
|
940
954
|
.default('auto')
|
|
@@ -969,7 +983,7 @@ class GscMcpRuntime {
|
|
|
969
983
|
)
|
|
970
984
|
.optional()
|
|
971
985
|
.describe(
|
|
972
|
-
"Optional filters ANDed together, e.g. [{ groupType: 'and', filters: [{ dimension: 'country', operator: 'equals', expression: 'usa' }] }]. Countries use ISO 3166-1 alpha-3 codes.",
|
|
986
|
+
"Optional filters ANDed together, e.g. [{ groupType: 'and', filters: [{ dimension: 'country', operator: 'equals', expression: 'usa' }] }]. Countries use ISO 3166-1 alpha-3 codes. Query filters are not supported when search_type is discover.",
|
|
973
987
|
),
|
|
974
988
|
},
|
|
975
989
|
outputSchema: SEARCH_ANALYTICS_OUTPUT_SCHEMA,
|
|
@@ -1041,16 +1055,16 @@ class GscMcpRuntime {
|
|
|
1041
1055
|
'insights.page_queries',
|
|
1042
1056
|
{
|
|
1043
1057
|
title: 'Find queries for a page',
|
|
1044
|
-
description: 'For one exact page URL, return the Search Console queries that produced impressions for it over a date range. This wraps an exact page dimension filter so agents do not need to construct analytics.query filter groups manually. Exact page matching is case-sensitive in Search Console. Use row_limit and start_row to page through bounded results; Search Console can still omit anonymized queries.',
|
|
1058
|
+
description: 'For one exact page URL, return the Search Console queries that produced impressions for it over a date range. This wraps an exact page dimension filter so agents do not need to construct analytics.query filter groups manually. Exact page matching is case-sensitive in Search Console. Use row_limit and start_row to page through bounded results; Search Console can still omit anonymized queries. Google Discover is intentionally unavailable because Discover does not expose query data.',
|
|
1045
1059
|
inputSchema: {
|
|
1046
1060
|
site_url: z.string().describe(SITE_URL_DESCRIPTION),
|
|
1047
1061
|
page_url: z.string().url().describe('Exact fully-qualified page URL to filter on, e.g. https://example.com/guides/seo/. Search Console exact page filters are case-sensitive.'),
|
|
1048
1062
|
start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
|
|
1049
1063
|
end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
|
|
1050
1064
|
search_type: z
|
|
1051
|
-
.enum(['web', 'image', 'video', 'news', '
|
|
1065
|
+
.enum(['web', 'image', 'video', 'news', 'googleNews'])
|
|
1052
1066
|
.default('web')
|
|
1053
|
-
.describe('Which search index to query. Defaults to web.'),
|
|
1067
|
+
.describe('Which search index to query. Defaults to web. Discover is not supported because this tool groups by query.'),
|
|
1054
1068
|
row_limit: z
|
|
1055
1069
|
.number()
|
|
1056
1070
|
.int()
|
|
@@ -1119,16 +1133,16 @@ class GscMcpRuntime {
|
|
|
1119
1133
|
'insights.query_pages',
|
|
1120
1134
|
{
|
|
1121
1135
|
title: 'Find pages for a query',
|
|
1122
|
-
description: 'For one exact search query, return the site pages that received impressions for it over a date range. This wraps an exact query dimension filter so agents do not need to construct analytics.query filter groups manually. Exact query matching is case-sensitive in Search Console. Use row_limit and start_row to page through bounded results and verify which URL Google is surfacing before diagnosing cannibalization or content targeting.',
|
|
1136
|
+
description: 'For one exact search query, return the site pages that received impressions for it over a date range. This wraps an exact query dimension filter so agents do not need to construct analytics.query filter groups manually. Exact query matching is case-sensitive in Search Console. Use row_limit and start_row to page through bounded results and verify which URL Google is surfacing before diagnosing cannibalization or content targeting. Google Discover is intentionally unavailable because Discover does not expose query data.',
|
|
1123
1137
|
inputSchema: {
|
|
1124
1138
|
site_url: z.string().describe(SITE_URL_DESCRIPTION),
|
|
1125
1139
|
query: z.string().min(1).describe('Exact Search Console query text to filter on. Exact query filters are case-sensitive.'),
|
|
1126
1140
|
start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
|
|
1127
1141
|
end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
|
|
1128
1142
|
search_type: z
|
|
1129
|
-
.enum(['web', 'image', 'video', 'news', '
|
|
1143
|
+
.enum(['web', 'image', 'video', 'news', 'googleNews'])
|
|
1130
1144
|
.default('web')
|
|
1131
|
-
.describe('Which search index to query. Defaults to web.'),
|
|
1145
|
+
.describe('Which search index to query. Defaults to web. Discover is not supported because this tool filters by query.'),
|
|
1132
1146
|
row_limit: z
|
|
1133
1147
|
.number()
|
|
1134
1148
|
.int()
|
|
@@ -1309,7 +1323,7 @@ class GscMcpRuntime {
|
|
|
1309
1323
|
'insights.quick_wins',
|
|
1310
1324
|
{
|
|
1311
1325
|
title: 'Identify SEO Quick Wins',
|
|
1312
|
-
description: 'Find
|
|
1326
|
+
description: 'Find observed query/page Search Analytics rows with at least the requested impressions whose average position falls in a configurable opportunity range (8-20 by default). Average position is an aggregate Search Console metric, not a literal current rank; CTR is returned for context and is not an eligibility filter. Results are ordered deterministically by impressions, then bounded with limit/start_row and explicit result_page metadata. Source pagination separately flags the local 100,000-row safety ceiling.',
|
|
1313
1327
|
inputSchema: createQuickWinsInputSchema(SITE_URL_DESCRIPTION),
|
|
1314
1328
|
outputSchema: z.object(QUICK_WIN_OUTPUT_SCHEMA),
|
|
1315
1329
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
@@ -1329,6 +1343,7 @@ class GscMcpRuntime {
|
|
|
1329
1343
|
const quickWins = processQuickWins(source.rows, min_impressions, min_position, max_position);
|
|
1330
1344
|
const bounded = windowKnownResults(quickWins, start_row, limit);
|
|
1331
1345
|
const payload = {
|
|
1346
|
+
note: 'Candidates are observed query/page rows selected by Search Console average position, not proof of a stable or current rank. CTR is reported for context and does not affect eligibility.',
|
|
1332
1347
|
quick_wins: bounded.items,
|
|
1333
1348
|
pagination: paginationMetadata(source),
|
|
1334
1349
|
result_page: bounded.resultPage,
|
|
@@ -1341,7 +1356,7 @@ class GscMcpRuntime {
|
|
|
1341
1356
|
'insights.cannibalization',
|
|
1342
1357
|
{
|
|
1343
1358
|
title: 'Detect Keyword Cannibalization',
|
|
1344
|
-
description: 'Analyze query/page Search Analytics to find queries split across multiple pages. Candidates are ranked deterministically by
|
|
1359
|
+
description: 'Analyze query/page Search Analytics to find queries split across multiple pages. Candidates are ranked deterministically by the observed query/page impression sum, each candidate bounds its page list, and limit/start_row plus result_page provide safe pagination. total_clicks, total_impressions, and impression_share are calculated from observed query/page rows and are not true query-level property aggregates; multiple pages can make that row sum exceed the query-level Search Console total. Multiple ranking URLs can also reflect legitimate locale or intent differences, so treat candidates as evidence to investigate rather than proof of harmful cannibalization.',
|
|
1345
1360
|
inputSchema: {
|
|
1346
1361
|
site_url: z.string().describe(SITE_URL_DESCRIPTION),
|
|
1347
1362
|
start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
|
|
@@ -1571,7 +1586,7 @@ class GscMcpRuntime {
|
|
|
1571
1586
|
'analytics.compare',
|
|
1572
1587
|
{
|
|
1573
1588
|
title: 'Compare Performance Between Periods',
|
|
1574
|
-
description: 'Compare Search Console performance metrics (clicks, impressions, CTR, average position) between two distinct date ranges (Period A vs Period B) for a selected dimension (query, page, country, device). Apply the same search type and optional dimension filters to both periods. Results are ranked by largest absolute click change, then impression change, and safely paged with limit/start_row. Percentage change is null when the baseline is zero and the comparison value differs.',
|
|
1589
|
+
description: 'Compare Search Console performance metrics (clicks, impressions, CTR, average position) between two distinct date ranges (Period A vs Period B) for a selected dimension (query, page, country, device). Apply the same search type and optional dimension filters to both periods. Results are ranked by largest absolute click change, then impression change, and safely paged with limit/start_row. Percentage change is null when the baseline is zero and the comparison value differs. Discover is intentionally unavailable because this comparison contract includes average position, which the Discover report does not support.',
|
|
1575
1590
|
inputSchema: {
|
|
1576
1591
|
site_url: z.string().describe(SITE_URL_DESCRIPTION),
|
|
1577
1592
|
start_date_a: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date of Period A (recent, YYYY-MM-DD)'),
|
|
@@ -1580,9 +1595,9 @@ class GscMcpRuntime {
|
|
|
1580
1595
|
end_date_b: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date of Period B (previous, YYYY-MM-DD)'),
|
|
1581
1596
|
dimension: z.enum(['query', 'page', 'country', 'device']).default('query').describe('The dimension to compare performance for. Defaults to query.'),
|
|
1582
1597
|
search_type: z
|
|
1583
|
-
.enum(['web', 'image', 'video', 'news', '
|
|
1598
|
+
.enum(['web', 'image', 'video', 'news', 'googleNews'])
|
|
1584
1599
|
.default('web')
|
|
1585
|
-
.describe('Which Search Console search index to compare. The same search type is used for both periods.'),
|
|
1600
|
+
.describe('Which Search Console search index to compare. The same search type is used for both periods. Discover is excluded because this tool returns average-position comparisons.'),
|
|
1586
1601
|
dimension_filter_groups: z
|
|
1587
1602
|
.array(
|
|
1588
1603
|
z.object({
|
package/src/quick-wins-schema.ts
CHANGED
|
@@ -11,9 +11,9 @@ const POSITION_RANGE_ERROR = {
|
|
|
11
11
|
};
|
|
12
12
|
|
|
13
13
|
const QUICK_WINS_THRESHOLDS_SHAPE = {
|
|
14
|
-
min_impressions: z.number().int().min(0).default(100).describe('Minimum impressions required to consider
|
|
15
|
-
min_position: z.number().positive().default(8).describe('Minimum average position
|
|
16
|
-
max_position: z.number().positive().default(20).describe('Maximum average position
|
|
14
|
+
min_impressions: z.number().int().min(0).default(100).describe('Minimum impressions required to consider an observed query/page row. Must be at least 0. Default is 100.'),
|
|
15
|
+
min_position: z.number().positive().default(8).describe('Minimum Search Console average position for the opportunity range (inclusive). This is an aggregate metric, not a literal current rank. Must be greater than 0. Default is 8.'),
|
|
16
|
+
max_position: z.number().positive().default(20).describe('Maximum Search Console average position for the opportunity range (inclusive). This is an aggregate metric, not a literal current rank. Must be greater than 0. Default is 20.'),
|
|
17
17
|
};
|
|
18
18
|
|
|
19
19
|
export function createQuickWinsInputSchema(siteUrlDescription: string) {
|
|
@@ -29,13 +29,13 @@ export function createQuickWinsInputSchema(siteUrlDescription: string) {
|
|
|
29
29
|
.min(1)
|
|
30
30
|
.max(MAX_ANALYSIS_RESULT_LIMIT)
|
|
31
31
|
.default(DEFAULT_ANALYSIS_RESULT_LIMIT)
|
|
32
|
-
.describe('Maximum
|
|
32
|
+
.describe('Maximum ordered quick-win results to return in this response.'),
|
|
33
33
|
start_row: z
|
|
34
34
|
.number()
|
|
35
35
|
.int()
|
|
36
36
|
.min(0)
|
|
37
37
|
.default(0)
|
|
38
|
-
.describe('Zero-based offset into the
|
|
38
|
+
.describe('Zero-based offset into the ordered quick-win result list.'),
|
|
39
39
|
})
|
|
40
40
|
.refine(
|
|
41
41
|
({ min_position, max_position }) => min_position <= max_position,
|