@gscdump/analysis 1.4.0 → 1.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/dist/action-priority.d.mts +34 -0
- package/dist/action-priority.mjs +201 -0
- package/dist/analyzer/adapt-rows.mjs +12 -0
- package/dist/analyzer/all.mjs +61 -0
- package/dist/analyzer/paginate.mjs +85 -0
- package/dist/analyzer/row-analyzers.d.mts +3 -0
- package/dist/analyzer/row-analyzers.mjs +3 -0
- package/dist/analyzer/row-values.mjs +22 -0
- package/dist/analyzers/bayesian-ctr.mjs +177 -0
- package/dist/analyzers/bipartite-pagerank.mjs +243 -0
- package/dist/analyzers/brand.d.mts +33 -0
- package/dist/analyzers/brand.mjs +154 -0
- package/dist/analyzers/cannibalization.d.mts +58 -0
- package/dist/analyzers/cannibalization.mjs +267 -0
- package/dist/analyzers/change-point.mjs +197 -0
- package/dist/analyzers/clustering.d.mts +29 -0
- package/dist/analyzers/clustering.mjs +217 -0
- package/dist/analyzers/concentration.d.mts +42 -0
- package/dist/analyzers/concentration.mjs +180 -0
- package/dist/analyzers/content-velocity.mjs +87 -0
- package/dist/analyzers/ctr-anomaly.mjs +189 -0
- package/dist/analyzers/ctr-curve.mjs +128 -0
- package/dist/analyzers/dark-traffic.mjs +130 -0
- package/dist/analyzers/data-detail.mjs +44 -0
- package/dist/analyzers/data-query.mjs +44 -0
- package/dist/analyzers/decay.d.mts +38 -0
- package/dist/analyzers/decay.mjs +187 -0
- package/dist/analyzers/device-gap.mjs +135 -0
- package/dist/analyzers/intent-atlas.mjs +175 -0
- package/dist/analyzers/keyword-breadth.mjs +115 -0
- package/dist/analyzers/long-tail.mjs +150 -0
- package/dist/analyzers/movers.d.mts +43 -0
- package/dist/analyzers/movers.mjs +283 -0
- package/dist/analyzers/opportunity.d.mts +32 -0
- package/dist/analyzers/opportunity.mjs +212 -0
- package/dist/analyzers/position-distribution.mjs +57 -0
- package/dist/analyzers/position-volatility.mjs +143 -0
- package/dist/analyzers/query-migration.mjs +178 -0
- package/dist/analyzers/seasonality.d.mts +28 -0
- package/dist/analyzers/seasonality.mjs +136 -0
- package/dist/analyzers/stl-decompose.mjs +187 -0
- package/dist/analyzers/striking-distance.d.mts +54 -0
- package/dist/analyzers/striking-distance.mjs +82 -0
- package/dist/analyzers/survival.mjs +200 -0
- package/dist/analyzers/trends.mjs +156 -0
- package/dist/analyzers/zero-click.d.mts +17 -0
- package/dist/analyzers/zero-click.mjs +122 -0
- package/dist/browser.d.mts +5 -0
- package/dist/browser.mjs +11 -0
- package/dist/default-registry.d.mts +3 -1
- package/dist/default-registry.mjs +5 -1
- package/dist/errors.d.mts +51 -1
- package/dist/index.d.mts +30 -494
- package/dist/index.mjs +28 -49
- package/dist/query/analyzers.mjs +301 -0
- package/dist/query/index.mjs +4 -0
- package/dist/query/intent.d.mts +20 -0
- package/dist/query/intent.mjs +139 -0
- package/dist/query/normalize.d.mts +15 -0
- package/dist/query/normalize.mjs +204 -0
- package/dist/report/format.d.mts +7 -0
- package/dist/report/format.mjs +45 -0
- package/dist/report/index.d.mts +4 -1
- package/dist/report/index.mjs +4 -1
- package/dist/report/registry.d.mts +4 -0
- package/dist/report/registry.mjs +26 -0
- package/dist/report/reports/brand.mjs +104 -0
- package/dist/report/reports/growth.mjs +144 -0
- package/dist/report/reports/health.mjs +141 -0
- package/dist/report/reports/movers.mjs +203 -0
- package/dist/report/reports/opportunities.mjs +184 -0
- package/dist/report/reports/pre-publish.mjs +111 -0
- package/dist/report/reports/priority.mjs +97 -0
- package/dist/report/reports/risks.mjs +201 -0
- package/dist/report/reports/triage.mjs +155 -0
- package/dist/report/require.mjs +15 -0
- package/dist/report/resolve-target.d.mts +29 -0
- package/dist/report/resolve-target.mjs +32 -0
- package/dist/{_chunks/index.d.mts → report/runtime.d.mts} +2 -37
- package/dist/report/runtime.mjs +80 -0
- package/dist/report/sections.mjs +22 -0
- package/dist/sitemap-health.d.mts +34 -0
- package/dist/sitemap-health.mjs +37 -0
- package/dist/{_chunks/index2.d.mts → source/composite.d.mts} +2 -13
- package/dist/{_chunks/source.mjs → source/composite.mjs} +1 -17
- package/dist/source/in-memory.d.mts +14 -0
- package/dist/source/in-memory.mjs +17 -0
- package/dist/source/index.d.mts +2 -1
- package/dist/source/index.mjs +2 -1
- package/dist/sql-analyzers.d.mts +3 -0
- package/dist/sql-analyzers.mjs +3 -0
- package/dist/types.d.mts +34 -0
- package/dist/types.mjs +21 -0
- package/package.json +4 -4
- package/dist/_chunks/default-registry.d.mts +0 -5
- package/dist/_chunks/default-registry.mjs +0 -5183
- package/dist/_chunks/errors.d.mts +0 -52
- package/dist/_chunks/report.mjs +0 -1710
- /package/dist/{_chunks/scoring.mjs → scoring.mjs} +0 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { parseJsonRows, rowString } from "../analyzer/row-values.mjs";
|
|
2
|
+
import { defineAnalyzer } from "@gscdump/engine/analyzer";
|
|
3
|
+
import { periodOf } from "@gscdump/engine/period";
|
|
4
|
+
import { enumeratePartitions } from "@gscdump/engine/planner";
|
|
5
|
+
import { num } from "@gscdump/engine/analysis-types";
|
|
6
|
+
const darkTrafficAnalyzer = defineAnalyzer({
|
|
7
|
+
id: "dark-traffic",
|
|
8
|
+
buildSql(params) {
|
|
9
|
+
const { startDate, endDate } = periodOf(params);
|
|
10
|
+
return {
|
|
11
|
+
sql: `
|
|
12
|
+
WITH page_totals AS (
|
|
13
|
+
SELECT SUM(clicks) AS total_clicks, SUM(impressions) AS total_impressions
|
|
14
|
+
FROM read_parquet({{FILES}}, union_by_name = true)
|
|
15
|
+
WHERE date >= ? AND date <= ?
|
|
16
|
+
),
|
|
17
|
+
kw_totals AS (
|
|
18
|
+
SELECT SUM(clicks) AS total_clicks, SUM(impressions) AS total_impressions
|
|
19
|
+
FROM read_parquet({{FILES_KEYWORDS}}, union_by_name = true)
|
|
20
|
+
WHERE date >= ? AND date <= ?
|
|
21
|
+
),
|
|
22
|
+
per_page AS (
|
|
23
|
+
SELECT url, SUM(clicks) AS page_clicks
|
|
24
|
+
FROM read_parquet({{FILES}}, union_by_name = true)
|
|
25
|
+
WHERE date >= ? AND date <= ?
|
|
26
|
+
GROUP BY url
|
|
27
|
+
HAVING SUM(clicks) > 0
|
|
28
|
+
),
|
|
29
|
+
per_page_kw AS (
|
|
30
|
+
SELECT url, SUM(clicks) AS attributed_clicks, COUNT(DISTINCT query) AS kw_count
|
|
31
|
+
FROM read_parquet({{FILES_PAGE_KEYWORDS}}, union_by_name = true)
|
|
32
|
+
WHERE date >= ? AND date <= ?
|
|
33
|
+
GROUP BY url
|
|
34
|
+
),
|
|
35
|
+
page_rows AS (
|
|
36
|
+
SELECT
|
|
37
|
+
p.url AS url,
|
|
38
|
+
CAST(p.page_clicks AS DOUBLE) AS totalClicks,
|
|
39
|
+
CAST(COALESCE(k.attributed_clicks, 0) AS DOUBLE) AS attributedClicks,
|
|
40
|
+
CAST(p.page_clicks - COALESCE(k.attributed_clicks, 0) AS DOUBLE) AS darkClicks,
|
|
41
|
+
CAST(p.page_clicks - COALESCE(k.attributed_clicks, 0) AS DOUBLE)
|
|
42
|
+
/ NULLIF(p.page_clicks, 0) AS darkPercent,
|
|
43
|
+
CAST(COALESCE(k.kw_count, 0) AS DOUBLE) AS keywordCount
|
|
44
|
+
FROM per_page p
|
|
45
|
+
LEFT JOIN per_page_kw k ON p.url = k.url
|
|
46
|
+
WHERE p.page_clicks - COALESCE(k.attributed_clicks, 0) > 0
|
|
47
|
+
ORDER BY darkClicks DESC
|
|
48
|
+
LIMIT 50
|
|
49
|
+
)
|
|
50
|
+
SELECT
|
|
51
|
+
(SELECT to_json({
|
|
52
|
+
'totalClicks': CAST(total_clicks AS DOUBLE),
|
|
53
|
+
'totalImpressions': CAST(total_impressions AS DOUBLE)
|
|
54
|
+
}) FROM page_totals) AS page_totals_json,
|
|
55
|
+
(SELECT to_json({
|
|
56
|
+
'attributedClicks': CAST(total_clicks AS DOUBLE),
|
|
57
|
+
'attributedImpressions': CAST(total_impressions AS DOUBLE)
|
|
58
|
+
}) FROM kw_totals) AS kw_totals_json,
|
|
59
|
+
(SELECT to_json(list({
|
|
60
|
+
'url': url,
|
|
61
|
+
'totalClicks': totalClicks,
|
|
62
|
+
'attributedClicks': attributedClicks,
|
|
63
|
+
'darkClicks': darkClicks,
|
|
64
|
+
'darkPercent': darkPercent,
|
|
65
|
+
'keywordCount': keywordCount
|
|
66
|
+
})) FROM page_rows) AS pages_json
|
|
67
|
+
`,
|
|
68
|
+
params: [
|
|
69
|
+
startDate,
|
|
70
|
+
endDate,
|
|
71
|
+
startDate,
|
|
72
|
+
endDate,
|
|
73
|
+
startDate,
|
|
74
|
+
endDate,
|
|
75
|
+
startDate,
|
|
76
|
+
endDate
|
|
77
|
+
],
|
|
78
|
+
current: {
|
|
79
|
+
table: "pages",
|
|
80
|
+
partitions: enumeratePartitions(startDate, endDate)
|
|
81
|
+
},
|
|
82
|
+
extraFiles: {
|
|
83
|
+
KEYWORDS: {
|
|
84
|
+
table: "queries",
|
|
85
|
+
partitions: enumeratePartitions(startDate, endDate)
|
|
86
|
+
},
|
|
87
|
+
PAGE_KEYWORDS: {
|
|
88
|
+
table: "page_queries",
|
|
89
|
+
partitions: enumeratePartitions(startDate, endDate)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
reduceSql(rows, params) {
|
|
95
|
+
const arr = Array.isArray(rows) ? rows : [];
|
|
96
|
+
const { startDate, endDate } = periodOf(params);
|
|
97
|
+
const row = arr[0] ?? {};
|
|
98
|
+
const pageTotals = typeof row.page_totals_json === "string" ? JSON.parse(row.page_totals_json) : row.page_totals_json ?? {};
|
|
99
|
+
const kwTotals = typeof row.kw_totals_json === "string" ? JSON.parse(row.kw_totals_json) : row.kw_totals_json ?? {};
|
|
100
|
+
const totalClicks = num(pageTotals.totalClicks);
|
|
101
|
+
const totalImpressions = num(pageTotals.totalImpressions);
|
|
102
|
+
const attributedClicks = num(kwTotals.attributedClicks);
|
|
103
|
+
const attributedImpressions = num(kwTotals.attributedImpressions);
|
|
104
|
+
const darkClicks = Math.max(0, totalClicks - attributedClicks);
|
|
105
|
+
const darkPercent = totalClicks > 0 ? darkClicks / totalClicks : 0;
|
|
106
|
+
return {
|
|
107
|
+
results: parseJsonRows(row.pages_json).map((r) => ({
|
|
108
|
+
url: rowString(r.url),
|
|
109
|
+
totalClicks: num(r.totalClicks),
|
|
110
|
+
attributedClicks: num(r.attributedClicks),
|
|
111
|
+
darkClicks: num(r.darkClicks),
|
|
112
|
+
darkPercent: num(r.darkPercent),
|
|
113
|
+
keywordCount: num(r.keywordCount)
|
|
114
|
+
})),
|
|
115
|
+
meta: {
|
|
116
|
+
summary: {
|
|
117
|
+
totalClicks,
|
|
118
|
+
attributedClicks,
|
|
119
|
+
darkClicks,
|
|
120
|
+
darkPercent,
|
|
121
|
+
totalImpressions,
|
|
122
|
+
attributedImpressions
|
|
123
|
+
},
|
|
124
|
+
startDate,
|
|
125
|
+
endDate
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
export { darkTrafficAnalyzer };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { buildDataDetailPlan, buildDataDetailRows, shapeDataDetailRowResults, shapeDataDetailRows } from "../query/analyzers.mjs";
|
|
2
|
+
import "../query/index.mjs";
|
|
3
|
+
import { defineAnalyzer, requireAdapter } from "@gscdump/engine/analyzer";
|
|
4
|
+
const dataDetailAnalyzer = defineAnalyzer({
|
|
5
|
+
id: "data-detail",
|
|
6
|
+
sqlRequires: [
|
|
7
|
+
"executeSql",
|
|
8
|
+
"attachedTables",
|
|
9
|
+
"adapter"
|
|
10
|
+
],
|
|
11
|
+
buildSql(params, ctx) {
|
|
12
|
+
const plan = buildDataDetailPlan(params, {
|
|
13
|
+
adapter: requireAdapter(ctx, "data-detail"),
|
|
14
|
+
siteId: ctx.siteId
|
|
15
|
+
});
|
|
16
|
+
return {
|
|
17
|
+
sql: plan.sql,
|
|
18
|
+
params: plan.params,
|
|
19
|
+
current: {
|
|
20
|
+
table: plan.tableKey,
|
|
21
|
+
partitions: []
|
|
22
|
+
},
|
|
23
|
+
extraQueries: plan.extraQueries
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
reduceSql(rows, params, ctx) {
|
|
27
|
+
const { results, meta } = shapeDataDetailRows(Array.isArray(rows) ? rows : [], params, ctx.extras);
|
|
28
|
+
return {
|
|
29
|
+
results,
|
|
30
|
+
meta
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
buildRows(params) {
|
|
34
|
+
return buildDataDetailRows(params);
|
|
35
|
+
},
|
|
36
|
+
reduceRows(rows, params) {
|
|
37
|
+
const { results, meta } = shapeDataDetailRowResults(Array.isArray(rows) ? {} : rows, params);
|
|
38
|
+
return {
|
|
39
|
+
results,
|
|
40
|
+
meta
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
export { dataDetailAnalyzer };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { buildDataQueryPlan, buildDataQueryRows, shapeDataQueryRowResults, shapeDataQueryRows } from "../query/analyzers.mjs";
|
|
2
|
+
import "../query/index.mjs";
|
|
3
|
+
import { defineAnalyzer, requireAdapter } from "@gscdump/engine/analyzer";
|
|
4
|
+
const dataQueryAnalyzer = defineAnalyzer({
|
|
5
|
+
id: "data-query",
|
|
6
|
+
sqlRequires: [
|
|
7
|
+
"executeSql",
|
|
8
|
+
"attachedTables",
|
|
9
|
+
"adapter"
|
|
10
|
+
],
|
|
11
|
+
buildSql(params, ctx) {
|
|
12
|
+
const plan = buildDataQueryPlan(params, {
|
|
13
|
+
adapter: requireAdapter(ctx, "data-query"),
|
|
14
|
+
siteId: ctx.siteId
|
|
15
|
+
});
|
|
16
|
+
return {
|
|
17
|
+
sql: plan.sql,
|
|
18
|
+
params: plan.params,
|
|
19
|
+
current: {
|
|
20
|
+
table: plan.tableKey,
|
|
21
|
+
partitions: []
|
|
22
|
+
},
|
|
23
|
+
extraQueries: plan.extraQueries
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
reduceSql(rows, params, ctx) {
|
|
27
|
+
const { results, meta } = shapeDataQueryRows(Array.isArray(rows) ? rows : [], params, ctx.extras);
|
|
28
|
+
return {
|
|
29
|
+
results,
|
|
30
|
+
meta
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
buildRows(params) {
|
|
34
|
+
return buildDataQueryRows(params);
|
|
35
|
+
},
|
|
36
|
+
reduceRows(rows, params) {
|
|
37
|
+
const { results, meta } = shapeDataQueryRowResults(Array.isArray(rows) ? {} : rows, params);
|
|
38
|
+
return {
|
|
39
|
+
results,
|
|
40
|
+
meta
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
export { dataQueryAnalyzer };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { PageRow } from "../types.mjs";
|
|
2
|
+
type DecaySortMetric = 'lostClicks' | 'declinePercent' | 'currentClicks';
|
|
3
|
+
interface DecayOptions {
|
|
4
|
+
/** Minimum clicks in previous period to consider. Default: 50 */
|
|
5
|
+
minPreviousClicks?: number;
|
|
6
|
+
/** Minimum decline percentage (0-1). Default: 0.2 (20%) */
|
|
7
|
+
threshold?: number;
|
|
8
|
+
/** Metric to sort results by. Default: lostClicks */
|
|
9
|
+
sortBy?: DecaySortMetric;
|
|
10
|
+
}
|
|
11
|
+
interface DecayInput {
|
|
12
|
+
current: PageRow[];
|
|
13
|
+
previous: PageRow[];
|
|
14
|
+
}
|
|
15
|
+
interface DecaySeriesPoint {
|
|
16
|
+
week: string;
|
|
17
|
+
clicks: number;
|
|
18
|
+
impressions: number;
|
|
19
|
+
}
|
|
20
|
+
interface DecayResult {
|
|
21
|
+
page: string;
|
|
22
|
+
currentClicks: number;
|
|
23
|
+
previousClicks: number;
|
|
24
|
+
lostClicks: number;
|
|
25
|
+
declinePercent: number;
|
|
26
|
+
/** `null` when the page had no ranking data in the current period (not "position 0"). */
|
|
27
|
+
currentPosition: number | null;
|
|
28
|
+
previousPosition: number;
|
|
29
|
+
/** `null` unless both currentPosition and previousPosition exist. */
|
|
30
|
+
positionDrop: number | null;
|
|
31
|
+
series?: DecaySeriesPoint[];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Pure helper: identify "decaying" content — pages that have lost
|
|
35
|
+
* significant traffic between two periods.
|
|
36
|
+
*/
|
|
37
|
+
declare function analyzeDecay(input: DecayInput, options?: DecayOptions): DecayResult[];
|
|
38
|
+
export { DecayInput, DecayOptions, DecayResult, DecaySeriesPoint, DecaySortMetric, analyzeDecay };
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { parseJsonRows, rowString } from "../analyzer/row-values.mjs";
|
|
2
|
+
import { pagesQueryState } from "../analyzer/adapt-rows.mjs";
|
|
3
|
+
import { buildPeriodMap, createMetricSorter } from "../types.mjs";
|
|
4
|
+
import { paginateInMemory } from "../analyzer/paginate.mjs";
|
|
5
|
+
import { defineAnalyzer } from "@gscdump/engine/analyzer";
|
|
6
|
+
import { comparisonOf } from "@gscdump/engine/period";
|
|
7
|
+
import { enumeratePartitions } from "@gscdump/engine/planner";
|
|
8
|
+
import { METRIC_EXPR } from "@gscdump/engine/sql-fragments";
|
|
9
|
+
import { num } from "@gscdump/engine/analysis-types";
|
|
10
|
+
const sortResults = createMetricSorter("lostClicks", {
|
|
11
|
+
lostClicks: "desc",
|
|
12
|
+
declinePercent: "desc",
|
|
13
|
+
currentClicks: "asc"
|
|
14
|
+
});
|
|
15
|
+
function analyzeDecay(input, options = {}) {
|
|
16
|
+
const { minPreviousClicks = 50, threshold = .2, sortBy = "lostClicks" } = options;
|
|
17
|
+
const currentMap = buildPeriodMap(input.current, (r) => r.page, (r) => ({
|
|
18
|
+
clicks: num(r.clicks),
|
|
19
|
+
position: num(r.position)
|
|
20
|
+
}));
|
|
21
|
+
const previousMap = buildPeriodMap(input.previous, (r) => r.page, (r) => ({
|
|
22
|
+
clicks: num(r.clicks),
|
|
23
|
+
position: num(r.position)
|
|
24
|
+
}), (r) => num(r.clicks) >= minPreviousClicks);
|
|
25
|
+
const results = [];
|
|
26
|
+
for (const [page, prev] of previousMap) {
|
|
27
|
+
const curr = currentMap.get(page);
|
|
28
|
+
const currentClicks = curr?.clicks ?? 0;
|
|
29
|
+
const currentPosition = curr?.position ?? null;
|
|
30
|
+
const lostClicks = prev.clicks - currentClicks;
|
|
31
|
+
const declinePercent = prev.clicks > 0 ? lostClicks / prev.clicks : 0;
|
|
32
|
+
if (declinePercent >= threshold && lostClicks > 0) results.push({
|
|
33
|
+
page,
|
|
34
|
+
currentClicks,
|
|
35
|
+
previousClicks: prev.clicks,
|
|
36
|
+
lostClicks,
|
|
37
|
+
declinePercent,
|
|
38
|
+
currentPosition,
|
|
39
|
+
previousPosition: prev.position,
|
|
40
|
+
positionDrop: currentPosition != null ? currentPosition - prev.position : null
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return sortResults(results, sortBy);
|
|
44
|
+
}
|
|
45
|
+
const decayAnalyzer = defineAnalyzer({
|
|
46
|
+
id: "decay",
|
|
47
|
+
buildSql(params) {
|
|
48
|
+
const { current: cur, previous: prev } = comparisonOf(params);
|
|
49
|
+
const minPreviousClicks = params.minPreviousClicks ?? 50;
|
|
50
|
+
const threshold = params.threshold ?? .2;
|
|
51
|
+
return {
|
|
52
|
+
sql: `
|
|
53
|
+
WITH cur AS (
|
|
54
|
+
SELECT
|
|
55
|
+
url,
|
|
56
|
+
${METRIC_EXPR.clicks} AS clicks,
|
|
57
|
+
${METRIC_EXPR.position} AS position
|
|
58
|
+
FROM read_parquet({{FILES}}, union_by_name = true)
|
|
59
|
+
WHERE date >= ? AND date <= ?
|
|
60
|
+
GROUP BY url
|
|
61
|
+
),
|
|
62
|
+
prev AS (
|
|
63
|
+
SELECT
|
|
64
|
+
url,
|
|
65
|
+
${METRIC_EXPR.clicks} AS clicks,
|
|
66
|
+
${METRIC_EXPR.position} AS position
|
|
67
|
+
FROM read_parquet({{FILES_PREV}}, union_by_name = true)
|
|
68
|
+
WHERE date >= ? AND date <= ?
|
|
69
|
+
GROUP BY url
|
|
70
|
+
HAVING SUM(clicks) >= ?
|
|
71
|
+
),
|
|
72
|
+
weekly AS (
|
|
73
|
+
SELECT url, date_trunc('week', CAST(date AS DATE)) AS week,
|
|
74
|
+
${METRIC_EXPR.clicks} AS clicks,
|
|
75
|
+
${METRIC_EXPR.impressions} AS impressions
|
|
76
|
+
FROM (
|
|
77
|
+
SELECT url, date, clicks, impressions
|
|
78
|
+
FROM read_parquet({{FILES}}, union_by_name = true)
|
|
79
|
+
WHERE date >= ? AND date <= ?
|
|
80
|
+
UNION ALL
|
|
81
|
+
SELECT url, date, clicks, impressions
|
|
82
|
+
FROM read_parquet({{FILES_PREV}}, union_by_name = true)
|
|
83
|
+
WHERE date >= ? AND date <= ?
|
|
84
|
+
)
|
|
85
|
+
GROUP BY url, week
|
|
86
|
+
),
|
|
87
|
+
series_by_url AS (
|
|
88
|
+
SELECT url, to_json(list({
|
|
89
|
+
'week': strftime(week, '%Y-%m-%d'),
|
|
90
|
+
'clicks': clicks,
|
|
91
|
+
'impressions': impressions
|
|
92
|
+
} ORDER BY week)) AS seriesJson
|
|
93
|
+
FROM weekly GROUP BY url
|
|
94
|
+
),
|
|
95
|
+
joined AS (
|
|
96
|
+
SELECT
|
|
97
|
+
p.url AS page,
|
|
98
|
+
COALESCE(c.clicks, 0.0) AS currentClicks,
|
|
99
|
+
p.clicks AS previousClicks,
|
|
100
|
+
(p.clicks - COALESCE(c.clicks, 0.0)) AS lostClicks,
|
|
101
|
+
(p.clicks - COALESCE(c.clicks, 0.0)) / NULLIF(p.clicks, 0) AS declinePercent,
|
|
102
|
+
c.position AS currentPosition,
|
|
103
|
+
p.position AS previousPosition,
|
|
104
|
+
CASE WHEN c.position IS NOT NULL THEN (c.position - p.position) ELSE NULL END AS positionDrop,
|
|
105
|
+
s.seriesJson
|
|
106
|
+
FROM prev p
|
|
107
|
+
LEFT JOIN cur c ON p.url = c.url
|
|
108
|
+
LEFT JOIN series_by_url s ON p.url = s.url
|
|
109
|
+
)
|
|
110
|
+
SELECT *
|
|
111
|
+
FROM joined
|
|
112
|
+
WHERE declinePercent >= ? AND lostClicks > 0
|
|
113
|
+
ORDER BY lostClicks DESC
|
|
114
|
+
LIMIT 2000
|
|
115
|
+
`,
|
|
116
|
+
params: [
|
|
117
|
+
cur.startDate,
|
|
118
|
+
cur.endDate,
|
|
119
|
+
prev.startDate,
|
|
120
|
+
prev.endDate,
|
|
121
|
+
minPreviousClicks,
|
|
122
|
+
cur.startDate,
|
|
123
|
+
cur.endDate,
|
|
124
|
+
prev.startDate,
|
|
125
|
+
prev.endDate,
|
|
126
|
+
threshold
|
|
127
|
+
],
|
|
128
|
+
current: {
|
|
129
|
+
table: "pages",
|
|
130
|
+
partitions: enumeratePartitions(cur.startDate, cur.endDate)
|
|
131
|
+
},
|
|
132
|
+
previous: {
|
|
133
|
+
table: "pages",
|
|
134
|
+
partitions: enumeratePartitions(prev.startDate, prev.endDate)
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
reduceSql(rows, params) {
|
|
139
|
+
const mapped = (Array.isArray(rows) ? rows : []).map((r) => ({
|
|
140
|
+
page: rowString(r.page),
|
|
141
|
+
currentClicks: num(r.currentClicks),
|
|
142
|
+
previousClicks: num(r.previousClicks),
|
|
143
|
+
lostClicks: num(r.lostClicks),
|
|
144
|
+
declinePercent: num(r.declinePercent),
|
|
145
|
+
currentPosition: r.currentPosition == null ? null : num(r.currentPosition),
|
|
146
|
+
previousPosition: num(r.previousPosition),
|
|
147
|
+
positionDrop: r.positionDrop == null ? null : num(r.positionDrop),
|
|
148
|
+
series: parseJsonRows(r.seriesJson).map((s) => ({
|
|
149
|
+
week: rowString(s.week),
|
|
150
|
+
clicks: num(s.clicks),
|
|
151
|
+
impressions: num(s.impressions)
|
|
152
|
+
}))
|
|
153
|
+
}));
|
|
154
|
+
return {
|
|
155
|
+
results: paginateInMemory(mapped, {
|
|
156
|
+
limit: params.limit ?? 2e3,
|
|
157
|
+
offset: params.offset
|
|
158
|
+
}),
|
|
159
|
+
meta: { total: mapped.length }
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
buildRows(params) {
|
|
163
|
+
const { current, previous } = comparisonOf(params);
|
|
164
|
+
return {
|
|
165
|
+
current: pagesQueryState(current, params.limit),
|
|
166
|
+
previous: pagesQueryState(previous, params.limit)
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
reduceRows(rows, params) {
|
|
170
|
+
const map = rows && !Array.isArray(rows) ? rows : {
|
|
171
|
+
current: [],
|
|
172
|
+
previous: []
|
|
173
|
+
};
|
|
174
|
+
const results = analyzeDecay({
|
|
175
|
+
current: map.current ?? [],
|
|
176
|
+
previous: map.previous ?? []
|
|
177
|
+
}, {
|
|
178
|
+
minPreviousClicks: params.minPreviousClicks,
|
|
179
|
+
threshold: params.threshold
|
|
180
|
+
});
|
|
181
|
+
return {
|
|
182
|
+
results,
|
|
183
|
+
meta: { total: results.length }
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
export { analyzeDecay, decayAnalyzer };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { rowString } from "../analyzer/row-values.mjs";
|
|
2
|
+
import { defineAnalyzer } from "@gscdump/engine/analyzer";
|
|
3
|
+
import { periodOf } from "@gscdump/engine/period";
|
|
4
|
+
import { enumeratePartitions } from "@gscdump/engine/planner";
|
|
5
|
+
import { num } from "@gscdump/engine/analysis-types";
|
|
6
|
+
const deviceGapAnalyzer = defineAnalyzer({
|
|
7
|
+
id: "device-gap",
|
|
8
|
+
buildSql(params) {
|
|
9
|
+
const { startDate, endDate } = periodOf(params);
|
|
10
|
+
return {
|
|
11
|
+
sql: `
|
|
12
|
+
WITH raw AS (
|
|
13
|
+
SELECT
|
|
14
|
+
date,
|
|
15
|
+
COALESCE(clicks_desktop, 0) AS c_desktop,
|
|
16
|
+
COALESCE(clicks_mobile, 0) AS c_mobile,
|
|
17
|
+
COALESCE(clicks_tablet, 0) AS c_tablet,
|
|
18
|
+
COALESCE(impressions_desktop, 0) AS i_desktop,
|
|
19
|
+
COALESCE(impressions_mobile, 0) AS i_mobile,
|
|
20
|
+
COALESCE(impressions_tablet, 0) AS i_tablet,
|
|
21
|
+
COALESCE(sum_position_desktop, 0) AS p_desktop,
|
|
22
|
+
COALESCE(sum_position_mobile, 0) AS p_mobile,
|
|
23
|
+
COALESCE(sum_position_tablet, 0) AS p_tablet
|
|
24
|
+
FROM read_parquet({{FILES}}, union_by_name = true)
|
|
25
|
+
WHERE date >= ? AND date <= ?
|
|
26
|
+
),
|
|
27
|
+
device_long AS (
|
|
28
|
+
SELECT date, 'DESKTOP' AS device, c_desktop AS clicks, i_desktop AS impressions, p_desktop AS sum_position FROM raw
|
|
29
|
+
UNION ALL
|
|
30
|
+
SELECT date, 'MOBILE', c_mobile, i_mobile, p_mobile FROM raw
|
|
31
|
+
UNION ALL
|
|
32
|
+
SELECT date, 'TABLET', c_tablet, i_tablet, p_tablet FROM raw
|
|
33
|
+
)
|
|
34
|
+
SELECT
|
|
35
|
+
date,
|
|
36
|
+
device,
|
|
37
|
+
CAST(SUM(clicks) AS DOUBLE) AS clicks,
|
|
38
|
+
CAST(SUM(impressions) AS DOUBLE) AS impressions,
|
|
39
|
+
CAST(SUM(clicks) AS DOUBLE) / NULLIF(SUM(impressions), 0) AS ctr,
|
|
40
|
+
SUM(sum_position) / NULLIF(SUM(impressions), 0) + 1 AS position
|
|
41
|
+
FROM device_long
|
|
42
|
+
GROUP BY date, device
|
|
43
|
+
ORDER BY date ASC
|
|
44
|
+
`,
|
|
45
|
+
params: [startDate, endDate],
|
|
46
|
+
current: {
|
|
47
|
+
table: "dates",
|
|
48
|
+
partitions: enumeratePartitions(startDate, endDate)
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
reduceSql(rows, params) {
|
|
53
|
+
const arr = Array.isArray(rows) ? rows : [];
|
|
54
|
+
const { startDate, endDate } = periodOf(params);
|
|
55
|
+
const typed = arr.map((r) => ({
|
|
56
|
+
date: rowString(r.date),
|
|
57
|
+
device: rowString(r.device).toUpperCase(),
|
|
58
|
+
clicks: num(r.clicks),
|
|
59
|
+
impressions: num(r.impressions),
|
|
60
|
+
ctr: num(r.ctr),
|
|
61
|
+
position: num(r.position)
|
|
62
|
+
}));
|
|
63
|
+
const byDate = /* @__PURE__ */ new Map();
|
|
64
|
+
for (const r of typed) {
|
|
65
|
+
const entry = byDate.get(r.date) ?? {};
|
|
66
|
+
const metrics = {
|
|
67
|
+
clicks: r.clicks,
|
|
68
|
+
impressions: r.impressions,
|
|
69
|
+
ctr: r.ctr,
|
|
70
|
+
position: r.position
|
|
71
|
+
};
|
|
72
|
+
if (r.device === "DESKTOP") entry.desktop = metrics;
|
|
73
|
+
else if (r.device === "MOBILE") entry.mobile = metrics;
|
|
74
|
+
byDate.set(r.date, entry);
|
|
75
|
+
}
|
|
76
|
+
const zero = {
|
|
77
|
+
clicks: 0,
|
|
78
|
+
impressions: 0,
|
|
79
|
+
ctr: 0,
|
|
80
|
+
position: 0
|
|
81
|
+
};
|
|
82
|
+
const daily = [...byDate.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([date, sides]) => {
|
|
83
|
+
const d = sides.desktop ?? zero;
|
|
84
|
+
const m = sides.mobile ?? zero;
|
|
85
|
+
return {
|
|
86
|
+
date,
|
|
87
|
+
desktop: d,
|
|
88
|
+
mobile: m,
|
|
89
|
+
gaps: {
|
|
90
|
+
ctrGap: d.ctr - m.ctr,
|
|
91
|
+
positionGap: m.position - d.position
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
const weekly = (start, end) => {
|
|
96
|
+
const slice = daily.slice(start, end);
|
|
97
|
+
if (slice.length === 0) return {
|
|
98
|
+
ctr: 0,
|
|
99
|
+
pos: 0
|
|
100
|
+
};
|
|
101
|
+
const sum = slice.reduce((acc, d) => ({
|
|
102
|
+
ctr: acc.ctr + d.gaps.ctrGap,
|
|
103
|
+
pos: acc.pos + d.gaps.positionGap
|
|
104
|
+
}), {
|
|
105
|
+
ctr: 0,
|
|
106
|
+
pos: 0
|
|
107
|
+
});
|
|
108
|
+
return {
|
|
109
|
+
ctr: sum.ctr / slice.length,
|
|
110
|
+
pos: sum.pos / slice.length
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
const first = weekly(0, 7);
|
|
114
|
+
const last = weekly(Math.max(0, daily.length - 7), daily.length);
|
|
115
|
+
const classify = (firstVal, lastVal) => {
|
|
116
|
+
const diff = Math.abs(lastVal) - Math.abs(firstVal);
|
|
117
|
+
if (Math.abs(diff) < .005) return "stable";
|
|
118
|
+
return diff < 0 ? "improving" : "worsening";
|
|
119
|
+
};
|
|
120
|
+
return {
|
|
121
|
+
results: daily,
|
|
122
|
+
meta: {
|
|
123
|
+
summary: {
|
|
124
|
+
avgCtrGap: daily.reduce((s, d) => s + d.gaps.ctrGap, 0) / Math.max(1, daily.length),
|
|
125
|
+
avgPositionGap: daily.reduce((s, d) => s + d.gaps.positionGap, 0) / Math.max(1, daily.length),
|
|
126
|
+
ctrGapTrend: classify(first.ctr, last.ctr),
|
|
127
|
+
positionGapTrend: classify(first.pos, last.pos)
|
|
128
|
+
},
|
|
129
|
+
startDate,
|
|
130
|
+
endDate
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
export { deviceGapAnalyzer };
|