@gscdump/analysis 1.4.0 → 1.4.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/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,175 @@
|
|
|
1
|
+
import { parseJsonRows, rowString } from "../analyzer/row-values.mjs";
|
|
2
|
+
import { defineAnalyzer } from "@gscdump/engine/analyzer";
|
|
3
|
+
import { defaultEndDate } from "@gscdump/engine/period";
|
|
4
|
+
import { enumeratePartitions } from "@gscdump/engine/planner";
|
|
5
|
+
import { METRIC_EXPR } from "@gscdump/engine/sql-fragments";
|
|
6
|
+
import { num } from "@gscdump/engine/analysis-types";
|
|
7
|
+
import { daysAgoUtc } from "gscdump/dates";
|
|
8
|
+
const INTENT_ATLAS_STOP_WORDS = [
|
|
9
|
+
"the",
|
|
10
|
+
"a",
|
|
11
|
+
"an",
|
|
12
|
+
"is",
|
|
13
|
+
"are",
|
|
14
|
+
"was",
|
|
15
|
+
"were",
|
|
16
|
+
"be",
|
|
17
|
+
"been",
|
|
18
|
+
"of",
|
|
19
|
+
"to",
|
|
20
|
+
"in",
|
|
21
|
+
"for",
|
|
22
|
+
"on",
|
|
23
|
+
"and",
|
|
24
|
+
"or",
|
|
25
|
+
"with",
|
|
26
|
+
"at",
|
|
27
|
+
"by",
|
|
28
|
+
"from",
|
|
29
|
+
"into",
|
|
30
|
+
"about",
|
|
31
|
+
"as",
|
|
32
|
+
"so",
|
|
33
|
+
"than",
|
|
34
|
+
"then",
|
|
35
|
+
"that",
|
|
36
|
+
"this",
|
|
37
|
+
"my",
|
|
38
|
+
"your",
|
|
39
|
+
"our",
|
|
40
|
+
"their",
|
|
41
|
+
"his",
|
|
42
|
+
"her",
|
|
43
|
+
"its",
|
|
44
|
+
"me",
|
|
45
|
+
"you",
|
|
46
|
+
"what",
|
|
47
|
+
"how",
|
|
48
|
+
"why",
|
|
49
|
+
"when",
|
|
50
|
+
"where",
|
|
51
|
+
"who",
|
|
52
|
+
"which",
|
|
53
|
+
"do",
|
|
54
|
+
"does"
|
|
55
|
+
];
|
|
56
|
+
const intentAtlasAnalyzer = defineAnalyzer({
|
|
57
|
+
id: "intent-atlas",
|
|
58
|
+
buildSql(params) {
|
|
59
|
+
const endDate = params.endDate ?? defaultEndDate();
|
|
60
|
+
const startDate = params.startDate ?? daysAgoUtc(90);
|
|
61
|
+
const minQueryImpressions = params.minImpressions ?? 20;
|
|
62
|
+
const minClusterSize = params.minClusterSize ?? 3;
|
|
63
|
+
const minTokenImpressions = 50;
|
|
64
|
+
const limit = params.limit ?? 200;
|
|
65
|
+
const stopList = INTENT_ATLAS_STOP_WORDS.map((w) => `'${w}'`).join(", ");
|
|
66
|
+
return {
|
|
67
|
+
sql: `
|
|
68
|
+
WITH queries AS (
|
|
69
|
+
SELECT
|
|
70
|
+
query,
|
|
71
|
+
${METRIC_EXPR.impressions} AS impressions,
|
|
72
|
+
${METRIC_EXPR.clicks} AS clicks,
|
|
73
|
+
${METRIC_EXPR.position} AS position
|
|
74
|
+
FROM read_parquet({{FILES}}, union_by_name = true)
|
|
75
|
+
WHERE date >= ? AND date <= ?
|
|
76
|
+
AND query IS NOT NULL AND query <> ''
|
|
77
|
+
GROUP BY query
|
|
78
|
+
HAVING SUM(impressions) >= ?
|
|
79
|
+
),
|
|
80
|
+
tokens AS (
|
|
81
|
+
SELECT q.query, q.impressions, q.clicks, q.position,
|
|
82
|
+
LOWER(t.token) AS token
|
|
83
|
+
FROM queries q,
|
|
84
|
+
unnest(regexp_split_to_array(LOWER(q.query), '\\s+')) AS t(token)
|
|
85
|
+
WHERE LENGTH(t.token) >= 3
|
|
86
|
+
AND LOWER(t.token) NOT IN (${stopList})
|
|
87
|
+
),
|
|
88
|
+
token_weights AS (
|
|
89
|
+
SELECT token,
|
|
90
|
+
SUM(impressions) AS token_impressions,
|
|
91
|
+
COUNT(DISTINCT query) AS query_count
|
|
92
|
+
FROM tokens
|
|
93
|
+
GROUP BY token
|
|
94
|
+
HAVING SUM(impressions) >= ${Number(minTokenImpressions)}
|
|
95
|
+
),
|
|
96
|
+
ranked_tokens AS (
|
|
97
|
+
SELECT t.query, t.token, tw.token_impressions,
|
|
98
|
+
ROW_NUMBER() OVER (
|
|
99
|
+
PARTITION BY t.query
|
|
100
|
+
ORDER BY tw.token_impressions DESC, t.token ASC
|
|
101
|
+
) AS rnk
|
|
102
|
+
FROM tokens t
|
|
103
|
+
JOIN token_weights tw USING (token)
|
|
104
|
+
),
|
|
105
|
+
cluster_keys AS (
|
|
106
|
+
SELECT query,
|
|
107
|
+
array_to_string(list(token ORDER BY token), ' + ') AS cluster_key
|
|
108
|
+
FROM ranked_tokens
|
|
109
|
+
WHERE rnk <= 2
|
|
110
|
+
GROUP BY query
|
|
111
|
+
HAVING COUNT(*) >= 2
|
|
112
|
+
),
|
|
113
|
+
clustered AS (
|
|
114
|
+
SELECT q.query, q.impressions, q.clicks, q.position, ck.cluster_key
|
|
115
|
+
FROM queries q
|
|
116
|
+
JOIN cluster_keys ck USING (query)
|
|
117
|
+
)
|
|
118
|
+
SELECT
|
|
119
|
+
cluster_key AS clusterKey,
|
|
120
|
+
COUNT(*) AS keywordCount,
|
|
121
|
+
SUM(impressions) AS totalImpressions,
|
|
122
|
+
SUM(clicks) AS totalClicks,
|
|
123
|
+
SUM(clicks) / NULLIF(SUM(impressions), 0) AS ctr,
|
|
124
|
+
SUM((position - 1) * impressions) / NULLIF(SUM(impressions), 0) + 1 AS avgPosition,
|
|
125
|
+
to_json(list({
|
|
126
|
+
'query': query,
|
|
127
|
+
'impressions': impressions,
|
|
128
|
+
'clicks': clicks,
|
|
129
|
+
'position': position
|
|
130
|
+
} ORDER BY impressions DESC)) AS keywords
|
|
131
|
+
FROM clustered
|
|
132
|
+
GROUP BY cluster_key
|
|
133
|
+
HAVING COUNT(*) >= ${Number(minClusterSize)}
|
|
134
|
+
ORDER BY totalImpressions DESC
|
|
135
|
+
LIMIT ${Number(limit)}
|
|
136
|
+
`,
|
|
137
|
+
params: [
|
|
138
|
+
startDate,
|
|
139
|
+
endDate,
|
|
140
|
+
minQueryImpressions
|
|
141
|
+
],
|
|
142
|
+
current: {
|
|
143
|
+
table: "queries",
|
|
144
|
+
partitions: enumeratePartitions(startDate, endDate)
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
},
|
|
148
|
+
reduceSql(rows) {
|
|
149
|
+
const clusters = (Array.isArray(rows) ? rows : []).map((r) => ({
|
|
150
|
+
clusterKey: rowString(r.clusterKey),
|
|
151
|
+
keywordCount: num(r.keywordCount),
|
|
152
|
+
totalImpressions: num(r.totalImpressions),
|
|
153
|
+
totalClicks: num(r.totalClicks),
|
|
154
|
+
ctr: num(r.ctr),
|
|
155
|
+
avgPosition: num(r.avgPosition),
|
|
156
|
+
keywords: parseJsonRows(r.keywords).slice(0, 25).map((k) => ({
|
|
157
|
+
query: rowString(k.query),
|
|
158
|
+
impressions: num(k.impressions),
|
|
159
|
+
clicks: num(k.clicks),
|
|
160
|
+
position: num(k.position)
|
|
161
|
+
}))
|
|
162
|
+
}));
|
|
163
|
+
const totalImpressions = clusters.reduce((s, c) => s + c.totalImpressions, 0);
|
|
164
|
+
const totalKeywords = clusters.reduce((s, c) => s + c.keywordCount, 0);
|
|
165
|
+
return {
|
|
166
|
+
results: clusters,
|
|
167
|
+
meta: {
|
|
168
|
+
total: clusters.length,
|
|
169
|
+
totalImpressions,
|
|
170
|
+
totalKeywords
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
export { intentAtlasAnalyzer };
|
|
@@ -0,0 +1,115 @@
|
|
|
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 { METRIC_EXPR } from "@gscdump/engine/sql-fragments";
|
|
6
|
+
import { num } from "@gscdump/engine/analysis-types";
|
|
7
|
+
const keywordBreadthAnalyzer = defineAnalyzer({
|
|
8
|
+
id: "keyword-breadth",
|
|
9
|
+
buildSql(params) {
|
|
10
|
+
const { startDate, endDate } = periodOf(params);
|
|
11
|
+
return {
|
|
12
|
+
sql: `
|
|
13
|
+
WITH per_page AS (
|
|
14
|
+
SELECT
|
|
15
|
+
url,
|
|
16
|
+
CAST(COUNT(DISTINCT query) AS DOUBLE) AS keywordCount,
|
|
17
|
+
${METRIC_EXPR.clicks} AS clicks,
|
|
18
|
+
${METRIC_EXPR.impressions} AS impressions
|
|
19
|
+
FROM read_parquet({{FILES}}, union_by_name = true)
|
|
20
|
+
WHERE date >= ? AND date <= ? AND impressions > 0
|
|
21
|
+
GROUP BY url
|
|
22
|
+
),
|
|
23
|
+
bucketed AS (
|
|
24
|
+
SELECT
|
|
25
|
+
CASE
|
|
26
|
+
WHEN keywordCount = 1 THEN '1'
|
|
27
|
+
WHEN keywordCount BETWEEN 2 AND 5 THEN '2-5'
|
|
28
|
+
WHEN keywordCount BETWEEN 6 AND 15 THEN '6-15'
|
|
29
|
+
WHEN keywordCount BETWEEN 16 AND 50 THEN '16-50'
|
|
30
|
+
ELSE '50+'
|
|
31
|
+
END AS bucket,
|
|
32
|
+
MIN(keywordCount) AS sort_key,
|
|
33
|
+
CAST(COUNT(*) AS DOUBLE) AS pageCount
|
|
34
|
+
FROM per_page
|
|
35
|
+
GROUP BY bucket
|
|
36
|
+
),
|
|
37
|
+
fragile AS (
|
|
38
|
+
SELECT url, keywordCount, clicks, impressions
|
|
39
|
+
FROM per_page
|
|
40
|
+
WHERE keywordCount <= 2 AND clicks >= 5
|
|
41
|
+
ORDER BY clicks DESC
|
|
42
|
+
LIMIT 20
|
|
43
|
+
),
|
|
44
|
+
authority AS (
|
|
45
|
+
SELECT url, keywordCount, clicks, impressions
|
|
46
|
+
FROM per_page
|
|
47
|
+
WHERE keywordCount >= 20
|
|
48
|
+
ORDER BY keywordCount DESC
|
|
49
|
+
LIMIT 20
|
|
50
|
+
),
|
|
51
|
+
stats AS (
|
|
52
|
+
SELECT
|
|
53
|
+
CAST(COUNT(*) AS DOUBLE) AS totalPages,
|
|
54
|
+
CAST(AVG(keywordCount) AS DOUBLE) AS avgKeywordsPerPage,
|
|
55
|
+
CAST(SUM(CASE WHEN keywordCount <= 2 THEN 1 ELSE 0 END) AS DOUBLE) AS fragileCount,
|
|
56
|
+
CAST(SUM(CASE WHEN keywordCount >= 20 THEN 1 ELSE 0 END) AS DOUBLE) AS authorityCount
|
|
57
|
+
FROM per_page
|
|
58
|
+
)
|
|
59
|
+
SELECT
|
|
60
|
+
(SELECT to_json(list({ 'bucket': bucket, 'pageCount': pageCount, 'sortKey': sort_key })) FROM bucketed) AS distribution_json,
|
|
61
|
+
(SELECT to_json(list({ 'url': url, 'keywordCount': keywordCount, 'clicks': clicks, 'impressions': impressions })) FROM fragile) AS fragile_json,
|
|
62
|
+
(SELECT to_json(list({ 'url': url, 'keywordCount': keywordCount, 'clicks': clicks, 'impressions': impressions })) FROM authority) AS authority_json,
|
|
63
|
+
(SELECT to_json({
|
|
64
|
+
'totalPages': totalPages,
|
|
65
|
+
'avgKeywordsPerPage': avgKeywordsPerPage,
|
|
66
|
+
'fragileCount': fragileCount,
|
|
67
|
+
'authorityCount': authorityCount
|
|
68
|
+
}) FROM stats) AS stats_json
|
|
69
|
+
`,
|
|
70
|
+
params: [startDate, endDate],
|
|
71
|
+
current: {
|
|
72
|
+
table: "page_queries",
|
|
73
|
+
partitions: enumeratePartitions(startDate, endDate)
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
reduceSql(rows, params) {
|
|
78
|
+
const arr = Array.isArray(rows) ? rows : [];
|
|
79
|
+
const { startDate, endDate } = periodOf(params);
|
|
80
|
+
const row = arr[0] ?? {};
|
|
81
|
+
const distribution = parseJsonRows(row.distribution_json).sort((a, b) => num(a.sortKey) - num(b.sortKey)).map((r) => ({
|
|
82
|
+
bucket: rowString(r.bucket),
|
|
83
|
+
pageCount: num(r.pageCount)
|
|
84
|
+
}));
|
|
85
|
+
const fragile = parseJsonRows(row.fragile_json).map((r) => ({
|
|
86
|
+
url: rowString(r.url),
|
|
87
|
+
keywordCount: num(r.keywordCount),
|
|
88
|
+
clicks: num(r.clicks),
|
|
89
|
+
impressions: num(r.impressions)
|
|
90
|
+
}));
|
|
91
|
+
const authority = parseJsonRows(row.authority_json).map((r) => ({
|
|
92
|
+
url: rowString(r.url),
|
|
93
|
+
keywordCount: num(r.keywordCount),
|
|
94
|
+
clicks: num(r.clicks),
|
|
95
|
+
impressions: num(r.impressions)
|
|
96
|
+
}));
|
|
97
|
+
const stats = typeof row.stats_json === "string" ? JSON.parse(row.stats_json) : row.stats_json ?? {};
|
|
98
|
+
return {
|
|
99
|
+
results: distribution,
|
|
100
|
+
meta: {
|
|
101
|
+
fragilePages: fragile,
|
|
102
|
+
authorityPages: authority,
|
|
103
|
+
summary: {
|
|
104
|
+
totalPages: num(stats.totalPages),
|
|
105
|
+
avgKeywordsPerPage: num(stats.avgKeywordsPerPage),
|
|
106
|
+
fragileCount: num(stats.fragileCount),
|
|
107
|
+
authorityCount: num(stats.authorityCount)
|
|
108
|
+
},
|
|
109
|
+
startDate,
|
|
110
|
+
endDate
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
export { keywordBreadthAnalyzer };
|
|
@@ -0,0 +1,150 @@
|
|
|
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 { METRIC_EXPR } from "@gscdump/engine/sql-fragments";
|
|
6
|
+
import { num } from "@gscdump/engine/analysis-types";
|
|
7
|
+
function downsampleLogRank(points) {
|
|
8
|
+
const toPoint = (p) => ({
|
|
9
|
+
rank: num(p.rank),
|
|
10
|
+
impressions: num(p.impressions),
|
|
11
|
+
clicks: num(p.clicks),
|
|
12
|
+
query: rowString(p.query)
|
|
13
|
+
});
|
|
14
|
+
if (points.length <= 80) return points.map(toPoint);
|
|
15
|
+
const sampled = points.slice(0, 10).map(toPoint);
|
|
16
|
+
let nextThreshold = 1.15;
|
|
17
|
+
for (let i = 10; i < points.length; i++) {
|
|
18
|
+
const point = points[i];
|
|
19
|
+
if (num(point.rank) >= nextThreshold) {
|
|
20
|
+
sampled.push(toPoint(point));
|
|
21
|
+
nextThreshold *= 1.15;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return sampled;
|
|
25
|
+
}
|
|
26
|
+
const longTailAnalyzer = defineAnalyzer({
|
|
27
|
+
id: "long-tail",
|
|
28
|
+
buildSql(params) {
|
|
29
|
+
const { startDate, endDate } = periodOf(params);
|
|
30
|
+
const minQueries = 10;
|
|
31
|
+
const minQueryImpressions = params.minImpressions ?? 5;
|
|
32
|
+
const limit = params.limit ?? 100;
|
|
33
|
+
return {
|
|
34
|
+
sql: `
|
|
35
|
+
WITH pq AS (
|
|
36
|
+
SELECT
|
|
37
|
+
url AS page,
|
|
38
|
+
query,
|
|
39
|
+
${METRIC_EXPR.impressions} AS impressions,
|
|
40
|
+
${METRIC_EXPR.clicks} AS clicks
|
|
41
|
+
FROM read_parquet({{FILES}}, union_by_name = true)
|
|
42
|
+
WHERE date >= ? AND date <= ?
|
|
43
|
+
AND query IS NOT NULL AND query <> ''
|
|
44
|
+
AND url IS NOT NULL AND url <> ''
|
|
45
|
+
GROUP BY url, query
|
|
46
|
+
HAVING SUM(impressions) >= ?
|
|
47
|
+
),
|
|
48
|
+
ranked AS (
|
|
49
|
+
SELECT
|
|
50
|
+
page, query, impressions, clicks,
|
|
51
|
+
ROW_NUMBER() OVER (PARTITION BY page ORDER BY impressions DESC, query ASC) AS rnk
|
|
52
|
+
FROM pq
|
|
53
|
+
),
|
|
54
|
+
log_space AS (
|
|
55
|
+
SELECT *,
|
|
56
|
+
LN(rnk) AS log_rank,
|
|
57
|
+
LN(impressions) AS log_impr
|
|
58
|
+
FROM ranked
|
|
59
|
+
),
|
|
60
|
+
fit AS (
|
|
61
|
+
SELECT
|
|
62
|
+
page,
|
|
63
|
+
COUNT(*) AS query_count,
|
|
64
|
+
SUM(impressions) AS total_impressions,
|
|
65
|
+
SUM(clicks) AS total_clicks,
|
|
66
|
+
REGR_SLOPE(log_impr, log_rank) AS slope,
|
|
67
|
+
REGR_INTERCEPT(log_impr, log_rank) AS intercept,
|
|
68
|
+
REGR_R2(log_impr, log_rank) AS r2,
|
|
69
|
+
MAX(impressions) AS head_impressions,
|
|
70
|
+
MAX(CASE WHEN rnk = 1 THEN impressions END) / NULLIF(SUM(impressions), 0) AS head_share
|
|
71
|
+
FROM log_space
|
|
72
|
+
GROUP BY page
|
|
73
|
+
HAVING COUNT(*) >= ${Number(minQueries)}
|
|
74
|
+
),
|
|
75
|
+
scatter AS (
|
|
76
|
+
SELECT
|
|
77
|
+
l.page,
|
|
78
|
+
to_json(list({
|
|
79
|
+
'rank': l.rnk,
|
|
80
|
+
'impressions': l.impressions,
|
|
81
|
+
'clicks': l.clicks,
|
|
82
|
+
'query': l.query
|
|
83
|
+
} ORDER BY l.rnk)) AS pointsJson
|
|
84
|
+
FROM log_space l
|
|
85
|
+
JOIN fit f USING (page)
|
|
86
|
+
GROUP BY l.page
|
|
87
|
+
)
|
|
88
|
+
SELECT
|
|
89
|
+
f.page,
|
|
90
|
+
f.query_count AS queryCount,
|
|
91
|
+
f.total_impressions AS totalImpressions,
|
|
92
|
+
f.total_clicks AS totalClicks,
|
|
93
|
+
f.slope AS slope,
|
|
94
|
+
f.intercept AS intercept,
|
|
95
|
+
f.r2 AS r2,
|
|
96
|
+
f.head_impressions AS headImpressions,
|
|
97
|
+
f.head_share AS headShare,
|
|
98
|
+
s.pointsJson AS pointsJson,
|
|
99
|
+
CASE
|
|
100
|
+
WHEN f.slope > -0.6 THEN 'flat-tail'
|
|
101
|
+
WHEN f.slope > -1.2 THEN 'balanced'
|
|
102
|
+
ELSE 'head-heavy'
|
|
103
|
+
END AS fingerprint
|
|
104
|
+
FROM fit f
|
|
105
|
+
LEFT JOIN scatter s USING (page)
|
|
106
|
+
ORDER BY f.total_impressions DESC
|
|
107
|
+
LIMIT ${Number(limit)}
|
|
108
|
+
`,
|
|
109
|
+
params: [
|
|
110
|
+
startDate,
|
|
111
|
+
endDate,
|
|
112
|
+
minQueryImpressions
|
|
113
|
+
],
|
|
114
|
+
current: {
|
|
115
|
+
table: "page_queries",
|
|
116
|
+
partitions: enumeratePartitions(startDate, endDate)
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
reduceSql(rows) {
|
|
121
|
+
const results = (Array.isArray(rows) ? rows : []).map((r) => ({
|
|
122
|
+
page: rowString(r.page),
|
|
123
|
+
queryCount: num(r.queryCount),
|
|
124
|
+
totalImpressions: num(r.totalImpressions),
|
|
125
|
+
totalClicks: num(r.totalClicks),
|
|
126
|
+
slope: num(r.slope),
|
|
127
|
+
intercept: num(r.intercept),
|
|
128
|
+
r2: num(r.r2),
|
|
129
|
+
headImpressions: num(r.headImpressions),
|
|
130
|
+
headShare: num(r.headShare),
|
|
131
|
+
fingerprint: rowString(r.fingerprint),
|
|
132
|
+
points: downsampleLogRank(parseJsonRows(r.pointsJson))
|
|
133
|
+
}));
|
|
134
|
+
const counts = {
|
|
135
|
+
"flat-tail": 0,
|
|
136
|
+
"balanced": 0,
|
|
137
|
+
"head-heavy": 0
|
|
138
|
+
};
|
|
139
|
+
for (const r of results) counts[r.fingerprint]++;
|
|
140
|
+
return {
|
|
141
|
+
results,
|
|
142
|
+
meta: {
|
|
143
|
+
total: results.length,
|
|
144
|
+
fingerprints: counts,
|
|
145
|
+
avgSlope: results.length > 0 ? results.reduce((s, r) => s + r.slope, 0) / results.length : 0
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
export { longTailAnalyzer };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { QueriesRow } from "../types.mjs";
|
|
2
|
+
type MoversSortMetric = 'clicks' | 'impressions' | 'clicksChange' | 'impressionsChange' | 'positionChange';
|
|
3
|
+
interface MoversOptions {
|
|
4
|
+
/** Minimum change threshold to flag. Default: 0.2 (20%) */
|
|
5
|
+
changeThreshold?: number;
|
|
6
|
+
/** Minimum impressions in recent period. Default: 50 */
|
|
7
|
+
minImpressions?: number;
|
|
8
|
+
/** Metric to sort results by. Default: clicksChange */
|
|
9
|
+
sortBy?: MoversSortMetric;
|
|
10
|
+
}
|
|
11
|
+
interface MoversInput {
|
|
12
|
+
current: QueriesRow[];
|
|
13
|
+
previous: QueriesRow[];
|
|
14
|
+
/** If periods have different lengths, provide normalization factor (previous/current) */
|
|
15
|
+
normalizationFactor?: number;
|
|
16
|
+
}
|
|
17
|
+
interface MoverData {
|
|
18
|
+
keyword: string;
|
|
19
|
+
page: string | null;
|
|
20
|
+
recentClicks: number;
|
|
21
|
+
recentImpressions: number;
|
|
22
|
+
recentPosition: number;
|
|
23
|
+
baselineClicks: number;
|
|
24
|
+
baselineImpressions: number;
|
|
25
|
+
/** `null` when the keyword has no previous-period data (a genuinely new query), not "position 0". */
|
|
26
|
+
baselinePosition: number | null;
|
|
27
|
+
clicksChange: number;
|
|
28
|
+
clicksChangePercent: number;
|
|
29
|
+
impressionsChangePercent: number;
|
|
30
|
+
/** `null` unless both recentPosition and baselinePosition exist. */
|
|
31
|
+
positionChange: number | null;
|
|
32
|
+
}
|
|
33
|
+
interface MoversResult {
|
|
34
|
+
rising: MoverData[];
|
|
35
|
+
declining: MoverData[];
|
|
36
|
+
stable: MoverData[];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Pure helper: identify "movers and shakers" — keywords with significant
|
|
40
|
+
* recent changes between two periods.
|
|
41
|
+
*/
|
|
42
|
+
declare function analyzeMovers(input: MoversInput, options?: MoversOptions): MoversResult;
|
|
43
|
+
export { MoverData, MoversInput, MoversOptions, MoversResult, MoversSortMetric, analyzeMovers };
|