@gscdump/analysis 0.38.2 → 0.40.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
@@ -89,7 +89,9 @@ const registry = createAnalyzerRegistry({ rows: ROW_ANALYZERS, sql: SQL_ANALYZER
89
89
  const result = await runAnalyzerFromSource(source, { type: 'striking-distance', minImpressions: 100 }, registry)
90
90
  ```
91
91
 
92
- `attachParquetIndex` and `attachSnapshotIndex` from `@gscdump/engine-duckdb-node` wire parquet files (per-day, per-month, or pre-baked `.duckdb` snapshots) into a DuckDB session. Pair with `analyzeInBrowser` from `@gscdump/analysis` for the attached-table dispatch path.
92
+ `attachParquetIndex` and `attachSnapshotIndex` from `@gscdump/engine/node`
93
+ wire parquet files (per-day, per-month, or pre-baked `.duckdb` snapshots) into
94
+ a Node DuckDB session.
93
95
 
94
96
  ## Browser (DuckDB-WASM)
95
97
 
@@ -98,7 +100,6 @@ import { analyzeInBrowser } from '@gscdump/analysis'
98
100
  // Compose your own narrow registry instead of pulling the kitchen-sink default
99
101
  // (which statically imports every SQL analyzer). For demo only:
100
102
  import { defaultAnalyzerRegistry } from '@gscdump/analysis/registry'
101
- import { createEngine } from '@gscdump/engine-duckdb-wasm'
102
103
 
103
104
  const result = await analyzeInBrowser(
104
105
  runner,
@@ -110,7 +111,10 @@ const result = await analyzeInBrowser(
110
111
 
111
112
  `analyzeInBrowser` wraps any runner with `query(sql, params, signal?)` in an `AnalysisQuerySource` with the `attachedTables` capability and dispatches via `runAnalyzerFromSource`.
112
113
 
113
- For typed drizzle-style access, `@gscdump/engine-duckdb-wasm` exports `createEngine` (a `SqlQuerySource`) plus `bootDuckDBWasm`, `attachParquetUrlTables`, `createBrowserAnalysisRuntime`, and `resolveWindow` (re-export from `@gscdump/engine/period`).
114
+ `@gscdump/engine-duckdb-wasm` exports `bootDuckDBWasm`,
115
+ `attachParquetUrlTables`, `createBrowserAnalysisRuntime`, and `resolveWindow`
116
+ (re-exported from `@gscdump/engine/period`). Browser analysis uses attached
117
+ tables rather than a canonical-schema `createEngine`; see ADR-0001.
114
118
 
115
119
  ## SQLite (D1 / Cloudflare Workers)
116
120
 
@@ -148,7 +152,7 @@ Available source factories:
148
152
  - `createCompositeSource({ engine, gsc })` — `@gscdump/analysis/source`; engine first, GSC fallback
149
153
  - `createInMemoryQuerySource({ queryRows })` — `@gscdump/analysis/source`
150
154
  - `createEngineQuerySource({ engine, ctx })` — `@gscdump/engine/source`
151
- - `createEngine({ ... })` — `@gscdump/engine-duckdb-wasm`, `@gscdump/engine-sqlite`, `@gscdump/engine-duckdb-node`
155
+ - `createEngine({ ... })` — `@gscdump/engine-sqlite`
152
156
 
153
157
  Portable analyzers currently cover the row-based tools:
154
158
  `striking-distance`, `opportunity`, `brand`, `clustering`, `concentration`,
@@ -192,7 +196,7 @@ Presets: `last-7d`, `last-28d`, `last-30d`, `last-90d`, `last-180d`, `last-365d`
192
196
 
193
197
  - [`gscdump`](../gscdump) — REST client + query builder (edge-safe).
194
198
  - [`@gscdump/engine`](../engine) — Parquet/DuckDB storage engine + analyzer/source/period contracts.
195
- - [`@gscdump/engine-duckdb-node`](../engine-duckdb-node) — Node DuckDB engine factory + parquet/snapshot attach helpers.
199
+ - [`@gscdump/engine/node`](../engine) — Node DuckDB handle + parquet/snapshot attach helpers.
196
200
  - [`@gscdump/engine-duckdb-wasm`](../engine-duckdb-wasm) — DuckDB-WASM browser runtime + drizzle adapter.
197
201
  - [`@gscdump/engine-sqlite`](../engine-sqlite) — SQLite / D1 dialect adapter.
198
202
  - [`@gscdump/engine-gsc-api`](../engine-gsc-api) — GSC live-API engine adapter.
@@ -81,6 +81,13 @@ interface BrandSegmentationOptions {
81
81
  /** Minimum impressions for a keyword to be included. Default: 10 */
82
82
  minImpressions?: number;
83
83
  }
84
+ interface BrandSegmentationRow {
85
+ query?: string;
86
+ keyword?: string;
87
+ variants?: readonly string[];
88
+ clicks?: number | null;
89
+ impressions?: number | null;
90
+ }
84
91
  interface BrandSummary {
85
92
  brandClicks: number;
86
93
  nonBrandClicks: number;
@@ -88,9 +95,9 @@ interface BrandSummary {
88
95
  brandImpressions: number;
89
96
  nonBrandImpressions: number;
90
97
  }
91
- interface BrandSegmentationResult {
92
- brand: QueriesRow[];
93
- nonBrand: QueriesRow[];
98
+ interface BrandSegmentationResult<T extends BrandSegmentationRow = QueriesRow> {
99
+ brand: T[];
100
+ nonBrand: T[];
94
101
  summary: BrandSummary;
95
102
  }
96
103
  interface BrandResultRow {
@@ -698,12 +705,6 @@ interface TrendsResult {
698
705
  series: TrendSeriesPoint[];
699
706
  }
700
707
  declare const trendsAnalyzer: import("@gscdump/engine/analyzer").DefinedAnalyzer;
701
- /**
702
- * `zero-click` — high impressions, low CTR, good ranking. SQL groups by
703
- * (query, url) and applies HAVING/WHERE pushdown; row reducer dedupes to the
704
- * best-positioned page per query (different semantics, kept for parity with
705
- * the existing live-GSC behavior).
706
- */
707
708
  interface ZeroClickResult {
708
709
  query: string;
709
710
  page: string;
@@ -6,7 +6,7 @@ import { num } from "@gscdump/engine/analysis-types";
6
6
  import { err, ok, unwrapResult } from "gscdump/result";
7
7
  import { between, date, extractDateRange, gsc, page, query } from "gscdump/query";
8
8
  import { MS_PER_DAY, daysAgoUtc, toIsoDate } from "gscdump/dates";
9
- import { buildExtrasQueries, buildTotalsSql, mergeExtras, resolveComparisonSQL, resolveToSQL, resolveToSQLOptimized } from "@gscdump/engine/resolver";
9
+ import { buildExtrasQueries, buildTotalsSql, mergeExtras, resolveComparisonSQL, resolveToSQLOptimized } from "@gscdump/engine/resolver";
10
10
  function rowNumber(value) {
11
11
  if (typeof value === "number") return Number.isFinite(value) ? value : 0;
12
12
  if (typeof value === "bigint") return Number(value);
@@ -522,8 +522,7 @@ function analyzeBrandSegmentation(keywords, options) {
522
522
  const impressions = num(row.impressions);
523
523
  if (impressions < minImpressions) continue;
524
524
  const clicks = num(row.clicks);
525
- const query = row.query.toLowerCase();
526
- if (lowerBrandTerms.some((term) => query.includes(term))) {
525
+ if ((row.variants?.length ? row.variants : [row.query ?? row.keyword ?? ""]).some((query) => lowerBrandTerms.some((term) => query.toLowerCase().includes(term)))) {
527
526
  brand.push(row);
528
527
  brandClicks += clicks;
529
528
  brandImpressions += impressions;
@@ -665,7 +664,7 @@ function createMetricSorter(defaultMetric, orderByMetric) {
665
664
  return [...items].sort((a, b) => (a[sortBy] - b[sortBy]) * mult);
666
665
  };
667
666
  }
668
- const sortRowResults$1 = createSorter((item, metric) => {
667
+ const sortRowResults = createSorter((item, metric) => {
669
668
  switch (metric) {
670
669
  case "clicks": return item.totalClicks;
671
670
  case "impressions": return item.totalImpressions;
@@ -712,7 +711,7 @@ function analyzeCannibalization(rows, options = {}) {
712
711
  positionSpread
713
712
  });
714
713
  }
715
- return sortRowResults$1(results, sortBy, sortOrder);
714
+ return sortRowResults(results, sortBy, sortOrder);
716
715
  }
717
716
  const cannibalizationAnalyzer = defineAnalyzer({
718
717
  id: "cannibalization",
@@ -2250,14 +2249,9 @@ function shapeDataQueryRows(rows, params, extras) {
2250
2249
  function buildDataDetailPlan(params, options) {
2251
2250
  const state = requireBuilderState(params.q, "data-detail");
2252
2251
  if (!state.dimensions.includes("date")) throw new Error("data-detail: `date` dimension is required");
2253
- const main = resolveToSQL(state, options);
2254
- const totals = buildTotalsSql(state, options);
2252
+ const main = resolveToSQLOptimized(state, options);
2255
2253
  const prev = optionalBuilderState(params.qc, "data-detail", "qc");
2256
- const extraQueries = [{
2257
- name: "totals",
2258
- sql: totals.sql,
2259
- params: totals.params
2260
- }];
2254
+ const extraQueries = [];
2261
2255
  if (prev) {
2262
2256
  const previousTotals = buildTotalsSql(prev, options);
2263
2257
  extraQueries.push({
@@ -2275,18 +2269,22 @@ function buildDataDetailPlan(params, options) {
2275
2269
  }
2276
2270
  function shapeDataDetailRows(rows, params, extras) {
2277
2271
  const { startDate: rangeStart, endDate: rangeEnd } = extractDateRange(requireBuilderState(params.q, "data-detail").filter);
2278
- const coerced = rows.map(coerceNumericCols);
2272
+ const first = rows[0];
2273
+ const totals = {
2274
+ clicks: Number(first?.totalClicks ?? 0),
2275
+ impressions: Number(first?.totalImpressions ?? 0),
2276
+ ctr: Number(first?.totalCtr ?? 0),
2277
+ position: Number(first?.totalPosition ?? 0)
2278
+ };
2279
+ const coerced = rows.map((raw) => {
2280
+ const { totalCount: _tc, totalClicks: _tclk, totalImpressions: _timp, totalCtr: _tctr, totalPosition: _tpos, sum_position: _sp, ...rest } = raw;
2281
+ return coerceNumericCols(rest);
2282
+ });
2279
2283
  const daily = rangeStart && rangeEnd ? padTimeseries(coerced, {
2280
2284
  startDate: rangeStart,
2281
2285
  endDate: rangeEnd
2282
2286
  }) : coerced;
2283
- const totalsRow = extras?.totals?.[0] ?? {};
2284
- const meta = { totals: {
2285
- clicks: Number(totalsRow.clicks ?? 0),
2286
- impressions: Number(totalsRow.impressions ?? 0),
2287
- ctr: Number(totalsRow.ctr ?? 0),
2288
- position: Number(totalsRow.position ?? 0)
2289
- } };
2287
+ const meta = { totals };
2290
2288
  if (extras?.prevTotals) {
2291
2289
  const previousTotalsRow = extras.prevTotals[0] ?? {};
2292
2290
  meta.previousTotals = {
@@ -2403,13 +2401,75 @@ function paginateInMemory(rows, input) {
2403
2401
  const o = clampOffset(input.offset);
2404
2402
  return rows.slice(o, o + l);
2405
2403
  }
2404
+ function paginateSortedInMemory(rows, input, compare) {
2405
+ const limit = clampLimit(input.limit, rows.length);
2406
+ const offset = clampOffset(input.offset);
2407
+ const selectedCount = Math.min(rows.length, offset + limit);
2408
+ if (limit === 0 || offset >= rows.length || selectedCount === 0) return [];
2409
+ const fullSort = () => [...rows].sort(compare).slice(offset, offset + limit);
2410
+ if (selectedCount >= rows.length / 2) return fullSort();
2411
+ let invalidComparison = false;
2412
+ const compareStable = (left, right) => {
2413
+ const order = compare(left.value, right.value);
2414
+ if (Number.isNaN(order)) {
2415
+ invalidComparison = true;
2416
+ return left.index - right.index;
2417
+ }
2418
+ return order || left.index - right.index;
2419
+ };
2420
+ const isWorse = (left, right) => compareStable(left, right) > 0;
2421
+ const heap = [];
2422
+ const siftUp = (start) => {
2423
+ let index = start;
2424
+ while (index > 0) {
2425
+ const parent = index - 1 >>> 1;
2426
+ if (!isWorse(heap[index], heap[parent])) break;
2427
+ const swap = heap[parent];
2428
+ heap[parent] = heap[index];
2429
+ heap[index] = swap;
2430
+ index = parent;
2431
+ }
2432
+ };
2433
+ const siftDown = () => {
2434
+ let index = 0;
2435
+ for (;;) {
2436
+ const left = index * 2 + 1;
2437
+ if (left >= heap.length) return;
2438
+ const right = left + 1;
2439
+ let worse = left;
2440
+ if (right < heap.length && isWorse(heap[right], heap[left])) worse = right;
2441
+ if (!isWorse(heap[worse], heap[index])) return;
2442
+ const swap = heap[index];
2443
+ heap[index] = heap[worse];
2444
+ heap[worse] = swap;
2445
+ index = worse;
2446
+ }
2447
+ };
2448
+ for (let index = 0; index < rows.length; index++) {
2449
+ const candidate = {
2450
+ value: rows[index],
2451
+ index
2452
+ };
2453
+ if (heap.length < selectedCount) {
2454
+ heap.push(candidate);
2455
+ siftUp(heap.length - 1);
2456
+ } else if (compareStable(candidate, heap[0]) < 0) {
2457
+ heap[0] = candidate;
2458
+ siftDown();
2459
+ }
2460
+ }
2461
+ if (invalidComparison) return fullSort();
2462
+ heap.sort(compareStable);
2463
+ if (invalidComparison) return fullSort();
2464
+ return heap.slice(offset, offset + limit).map((entry) => entry.value);
2465
+ }
2406
2466
  function resolveSort(input, allowed, defaults) {
2407
2467
  return {
2408
2468
  sortBy: input.sortBy && allowed.includes(input.sortBy) ? input.sortBy : defaults.sortBy,
2409
2469
  sortDir: input.sortDir === "asc" || input.sortDir === "desc" ? input.sortDir : defaults.sortDir
2410
2470
  };
2411
2471
  }
2412
- const sortResults$1 = createMetricSorter("lostClicks", {
2472
+ const sortResults = createMetricSorter("lostClicks", {
2413
2473
  lostClicks: "desc",
2414
2474
  declinePercent: "desc",
2415
2475
  currentClicks: "asc"
@@ -2442,7 +2502,7 @@ function analyzeDecay(input, options = {}) {
2442
2502
  positionDrop: currentPosition != null ? currentPosition - prev.position : null
2443
2503
  });
2444
2504
  }
2445
- return sortResults$1(results, sortBy);
2505
+ return sortResults(results, sortBy);
2446
2506
  }
2447
2507
  const decayAnalyzer = defineAnalyzer({
2448
2508
  id: "decay",
@@ -3441,12 +3501,52 @@ function calculateCtrGapScore(actualCtr, position) {
3441
3501
  const gap = expectedCtr - actualCtr;
3442
3502
  return Math.min(gap / expectedCtr, 1);
3443
3503
  }
3444
- const sortResults = createMetricSorter("opportunityScore", {
3504
+ const SORT_DIR = {
3445
3505
  opportunityScore: "desc",
3446
3506
  potentialClicks: "desc",
3447
3507
  impressions: "desc",
3448
3508
  position: "asc"
3449
- });
3509
+ };
3510
+ function analyzeOpportunity(keywords, options = {}) {
3511
+ const minImpressions = options.minImpressions ?? 100;
3512
+ const positionWeight = options.weights?.position ?? 1;
3513
+ const impressionsWeight = options.weights?.impressions ?? 1;
3514
+ const ctrGapWeight = options.weights?.ctrGap ?? 1;
3515
+ const totalWeight = positionWeight + impressionsWeight + ctrGapWeight;
3516
+ const sortBy = options.sortBy ?? "opportunityScore";
3517
+ const results = [];
3518
+ for (const row of keywords) {
3519
+ const impressions = num(row.impressions);
3520
+ if (impressions < minImpressions) continue;
3521
+ const position = num(row.position);
3522
+ const ctr = num(row.ctr);
3523
+ const clicks = num(row.clicks);
3524
+ const positionScore = calculatePositionScore(position);
3525
+ const impressionScore = calculateImpressionScore(impressions);
3526
+ const ctrGapScore = calculateCtrGapScore(ctr, position);
3527
+ const weightedProduct = positionScore ** positionWeight * impressionScore ** impressionsWeight * ctrGapScore ** ctrGapWeight;
3528
+ const opportunityScore = Math.round(weightedProduct ** (1 / totalWeight) * 100);
3529
+ const potentialClicks = Math.round(impressions * getExpectedCtr(Math.min(3, position)));
3530
+ results.push({
3531
+ keyword: row.query,
3532
+ page: row.page ?? null,
3533
+ clicks,
3534
+ impressions,
3535
+ ctr,
3536
+ position,
3537
+ opportunityScore,
3538
+ potentialClicks,
3539
+ factors: {
3540
+ positionScore,
3541
+ impressionScore,
3542
+ ctrGapScore
3543
+ }
3544
+ });
3545
+ }
3546
+ const direction = SORT_DIR[sortBy];
3547
+ results.sort((left, right) => direction === "asc" ? left[sortBy] - right[sortBy] : right[sortBy] - left[sortBy]);
3548
+ return options.limit ? results.slice(0, options.limit) : results;
3549
+ }
3450
3550
  const opportunityAnalyzer = defineAnalyzer({
3451
3551
  id: "opportunity",
3452
3552
  buildSql(params) {
@@ -3560,51 +3660,15 @@ const opportunityAnalyzer = defineAnalyzer({
3560
3660
  return { queries: queriesQueryState(periodOf(params), params.limit) };
3561
3661
  },
3562
3662
  reduceRows(rows, params) {
3563
- const keywords = (Array.isArray(rows) ? rows : []) ?? [];
3564
- const minImpressions = params.minImpressions ?? 100;
3565
- const positionWeight = 1;
3566
- const impressionsWeight = 1;
3567
- const ctrGapWeight = 1;
3568
- const sortBy = "opportunityScore";
3569
- const results = [];
3570
- for (const row of keywords) {
3571
- const impressions = num(row.impressions);
3572
- const position = num(row.position);
3573
- const ctr = num(row.ctr);
3574
- const clicks = num(row.clicks);
3575
- if (impressions < minImpressions) continue;
3576
- const positionScore = calculatePositionScore(position);
3577
- const impressionScore = calculateImpressionScore(impressions);
3578
- const ctrGapScore = calculateCtrGapScore(ctr, position);
3579
- const geometricMean = (positionScore ** positionWeight * impressionScore ** impressionsWeight * ctrGapScore ** ctrGapWeight) ** (1 / 3);
3580
- const opportunityScore = Math.round(geometricMean * 100);
3581
- const targetCtr = getExpectedCtr(Math.min(3, position));
3582
- const potentialClicks = Math.round(impressions * targetCtr);
3583
- results.push({
3584
- keyword: row.query,
3585
- page: row.page ?? null,
3586
- clicks,
3587
- impressions,
3588
- ctr,
3589
- position,
3590
- opportunityScore,
3591
- potentialClicks,
3592
- factors: {
3593
- positionScore,
3594
- impressionScore,
3595
- ctrGapScore
3596
- }
3597
- });
3598
- }
3599
- const sorted = sortResults(results, sortBy);
3600
- const paged = paginateInMemory(sorted, {
3663
+ const results = analyzeOpportunity((Array.isArray(rows) ? rows : []) ?? [], { minImpressions: params.minImpressions });
3664
+ const paged = paginateSortedInMemory(results, {
3601
3665
  limit: params.limit,
3602
3666
  offset: params.offset
3603
- });
3667
+ }, (left, right) => right.opportunityScore - left.opportunityScore);
3604
3668
  return {
3605
3669
  results: paged,
3606
3670
  meta: {
3607
- total: sorted.length,
3671
+ total: results.length,
3608
3672
  returned: paged.length
3609
3673
  }
3610
3674
  };
@@ -4276,6 +4340,12 @@ const stlDecomposeAnalyzer = defineAnalyzer({
4276
4340
  }
4277
4341
  });
4278
4342
  const DEFAULT_ROW_LIMIT$1 = 25e3;
4343
+ function paginateStrikingDistance(results, options) {
4344
+ return paginateSortedInMemory(results, {
4345
+ limit: options.limit ?? 1e3,
4346
+ offset: options.offset
4347
+ }, (left, right) => right.potentialClicks - left.potentialClicks);
4348
+ }
4279
4349
  function filterStrikingDistance(rows, options = {}) {
4280
4350
  const minPosition = options.minPosition ?? 4;
4281
4351
  const maxPosition = options.maxPosition ?? 20;
@@ -4306,11 +4376,7 @@ const strikingDistanceAnalyzer = defineAnalyzer({
4306
4376
  id: "striking-distance",
4307
4377
  reduce(rows, params) {
4308
4378
  const results = filterStrikingDistance(Array.isArray(rows) ? rows : [], params);
4309
- results.sort((a, b) => b.potentialClicks - a.potentialClicks);
4310
- const paged = paginateInMemory(results, {
4311
- limit: params.limit ?? 1e3,
4312
- offset: params.offset
4313
- });
4379
+ const paged = paginateStrikingDistance(results, params);
4314
4380
  return {
4315
4381
  results: paged,
4316
4382
  meta: {
@@ -4686,7 +4752,18 @@ const trendsAnalyzer = defineAnalyzer({
4686
4752
  }
4687
4753
  });
4688
4754
  const DEFAULT_ROW_LIMIT = 25e3;
4689
- const sortRowResults = createSorter((item) => item.impressions, "impressions");
4755
+ function analyzeZeroClick(rows, options = {}) {
4756
+ const minImpressions = options.minImpressions ?? 1e3;
4757
+ const maxCtr = options.maxCtr ?? .03;
4758
+ const maxPosition = options.maxPosition ?? 10;
4759
+ const queryMap = /* @__PURE__ */ new Map();
4760
+ for (const row of rows) {
4761
+ if (row.impressions < minImpressions || row.position > maxPosition || row.ctr > maxCtr) continue;
4762
+ const existing = queryMap.get(row.query);
4763
+ if (!existing || row.position < existing.position) queryMap.set(row.query, { ...row });
4764
+ }
4765
+ return [...queryMap.values()].sort((left, right) => right.impressions - left.impressions);
4766
+ }
4690
4767
  const zeroClickAnalyzer = defineAnalyzer({
4691
4768
  id: "zero-click",
4692
4769
  buildSql(params) {
@@ -4770,30 +4847,15 @@ const zeroClickAnalyzer = defineAnalyzer({
4770
4847
  return { rows: gsc.select(query, page).where(between(date, period.startDate, period.endDate)).limit(limit).getState() };
4771
4848
  },
4772
4849
  reduceRows(rows, params) {
4773
- const arr = Array.isArray(rows) ? rows : [];
4774
- const minImpressions = params.minImpressions ?? 1e3;
4775
- const maxCtr = params.maxCtr ?? .03;
4776
- const maxPosition = params.maxPosition ?? 10;
4777
- const queryMap = /* @__PURE__ */ new Map();
4778
- for (const row of arr) {
4779
- if (row.impressions < minImpressions) continue;
4780
- if (row.position > maxPosition) continue;
4781
- if (row.ctr > maxCtr) continue;
4782
- const existing = queryMap.get(row.query);
4783
- if (!existing || row.position < existing.position) queryMap.set(row.query, {
4784
- query: row.query,
4785
- page: row.page,
4786
- clicks: row.clicks,
4787
- impressions: row.impressions,
4788
- ctr: row.ctr,
4789
- position: row.position
4790
- });
4791
- }
4792
- const results = sortRowResults(Array.from(queryMap.values()), "impressions", "desc");
4793
- const paged = paginateInMemory(results, {
4850
+ const results = analyzeZeroClick(Array.isArray(rows) ? rows : [], {
4851
+ minImpressions: params.minImpressions ?? 1e3,
4852
+ maxCtr: params.maxCtr ?? .03,
4853
+ maxPosition: params.maxPosition ?? 10
4854
+ });
4855
+ const paged = paginateSortedInMemory(results, {
4794
4856
  limit: params.limit,
4795
4857
  offset: params.offset
4796
- });
4858
+ }, (left, right) => right.impressions - left.impressions);
4797
4859
  return {
4798
4860
  results: paged,
4799
4861
  meta: {