@gscdump/analysis 0.40.1 → 1.0.0
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 +5 -22
- package/dist/index.d.mts +2 -7
- package/dist/index.mjs +2 -14
- package/package.json +5 -28
- package/dist/analyzer/index.d.mts +0 -761
- package/dist/analyzer/index.mjs +0 -4868
- package/dist/semantic/index.d.mts +0 -117
- package/dist/semantic/index.mjs +0 -444
- package/dist/source/index.d.mts +0 -37
- package/dist/source/index.mjs +0 -60
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
import { AnalysisQuerySource, QueryRow } from "@gscdump/engine/source";
|
|
2
|
-
type ContentGapDevice = 'webgpu' | 'wasm';
|
|
3
|
-
type ContentGapEmbeddingRole = 'query' | 'passage';
|
|
4
|
-
type ContentGapExtractor = (texts: string[], opts: {
|
|
5
|
-
pooling: 'mean';
|
|
6
|
-
normalize: boolean;
|
|
7
|
-
}) => Promise<{
|
|
8
|
-
data: Float32Array;
|
|
9
|
-
dims: number[];
|
|
10
|
-
}>;
|
|
11
|
-
interface EmbeddedContentGapTexts {
|
|
12
|
-
vectors: Float32Array[];
|
|
13
|
-
hits: number;
|
|
14
|
-
misses: number;
|
|
15
|
-
}
|
|
16
|
-
interface ContentGapEmbeddingCache {
|
|
17
|
-
getMany: (role: ContentGapEmbeddingRole, texts: string[]) => Promise<Map<string, Float32Array>>;
|
|
18
|
-
putMany: (role: ContentGapEmbeddingRole, entries: Array<[string, Float32Array]>) => Promise<void>;
|
|
19
|
-
}
|
|
20
|
-
declare function createMemoryContentGapCache(): ContentGapEmbeddingCache;
|
|
21
|
-
interface ContentGapEmbeddingRuntime {
|
|
22
|
-
modelId: string;
|
|
23
|
-
queryPrefix: string;
|
|
24
|
-
selectDevice: (requested?: ContentGapDevice) => Promise<ContentGapDevice>;
|
|
25
|
-
loadExtractor: (device: ContentGapDevice) => Promise<ContentGapExtractor>;
|
|
26
|
-
embed: (extractor: ContentGapExtractor, role: ContentGapEmbeddingRole, texts: string[], transform: (text: string) => string, onProgress: (done: number, total: number) => void) => Promise<EmbeddedContentGapTexts>;
|
|
27
|
-
}
|
|
28
|
-
interface ContentGapQueryCandidate {
|
|
29
|
-
query: string;
|
|
30
|
-
impressions: number;
|
|
31
|
-
clicks: number;
|
|
32
|
-
avgPosition: number;
|
|
33
|
-
currentUrl: string;
|
|
34
|
-
}
|
|
35
|
-
interface ContentGapInputOptions {
|
|
36
|
-
maxQueries: number;
|
|
37
|
-
maxUrls: number;
|
|
38
|
-
minImpressions: number;
|
|
39
|
-
}
|
|
40
|
-
type ExecuteSql = (sql: string, params?: unknown[]) => Promise<QueryRow[]>;
|
|
41
|
-
type NormalizeUrl = (url: string) => string;
|
|
42
|
-
declare function fetchContentGapInputs(executeSql: ExecuteSql, options: ContentGapInputOptions, normalizeUrl: NormalizeUrl, now?: () => number): Promise<{
|
|
43
|
-
queries: ContentGapQueryCandidate[];
|
|
44
|
-
urls: string[];
|
|
45
|
-
sqlMs: number;
|
|
46
|
-
}>;
|
|
47
|
-
interface ContentGapResult {
|
|
48
|
-
query: string;
|
|
49
|
-
impressions: number;
|
|
50
|
-
clicks: number;
|
|
51
|
-
avgPosition: number;
|
|
52
|
-
currentUrl: string;
|
|
53
|
-
currentSimilarity: number;
|
|
54
|
-
suggestedUrl: string;
|
|
55
|
-
suggestedSimilarity: number;
|
|
56
|
-
alternatives: Array<{
|
|
57
|
-
url: string;
|
|
58
|
-
similarity: number;
|
|
59
|
-
}>;
|
|
60
|
-
divergence: number;
|
|
61
|
-
impact: number;
|
|
62
|
-
}
|
|
63
|
-
interface ContentGapProgress {
|
|
64
|
-
phase: 'idle' | 'loading-model' | 'fetching-data' | 'embedding-queries' | 'embedding-urls' | 'computing-gaps' | 'done' | 'error';
|
|
65
|
-
message: string;
|
|
66
|
-
done?: number;
|
|
67
|
-
total?: number;
|
|
68
|
-
modelMs?: number;
|
|
69
|
-
sqlMs?: number;
|
|
70
|
-
embedMs?: number;
|
|
71
|
-
computeMs?: number;
|
|
72
|
-
}
|
|
73
|
-
interface ContentGapOptions {
|
|
74
|
-
maxQueries?: number;
|
|
75
|
-
maxUrls?: number;
|
|
76
|
-
minImpressions?: number;
|
|
77
|
-
minDivergence?: number;
|
|
78
|
-
device?: 'webgpu' | 'wasm';
|
|
79
|
-
onProgress?: (progress: ContentGapProgress) => void;
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* Content-gap requires a source with a raw-SQL escape hatch. The analyzer's
|
|
83
|
-
* query shape (CTEs + window functions against `main.page_queries`) isn't
|
|
84
|
-
* expressible as a {@link BuilderState}, so it bypasses `queryRows` and
|
|
85
|
-
* goes directly through `source.executeSql`.
|
|
86
|
-
*/
|
|
87
|
-
declare class ContentGapSourceUnsupportedError extends Error {
|
|
88
|
-
constructor(kind: string);
|
|
89
|
-
}
|
|
90
|
-
interface ContentGapAnalysis {
|
|
91
|
-
results: ContentGapResult[];
|
|
92
|
-
meta: {
|
|
93
|
-
modelMs: number;
|
|
94
|
-
sqlMs: number;
|
|
95
|
-
embedMs: number;
|
|
96
|
-
computeMs: number;
|
|
97
|
-
cacheHits: number;
|
|
98
|
-
totalInputs: number;
|
|
99
|
-
device: 'webgpu' | 'wasm';
|
|
100
|
-
modelId: string;
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
interface ContentGapAnalyzer {
|
|
104
|
-
analyze: (source: AnalysisQuerySource, opts?: ContentGapOptions) => Promise<ContentGapAnalysis>;
|
|
105
|
-
}
|
|
106
|
-
interface CreateContentGapAnalyzerOptions {
|
|
107
|
-
embeddings?: ContentGapEmbeddingRuntime;
|
|
108
|
-
loadInputs?: typeof fetchContentGapInputs;
|
|
109
|
-
now?: () => number;
|
|
110
|
-
}
|
|
111
|
-
declare function normalizeUrl(u: string): string;
|
|
112
|
-
declare function deriveUrlText(url: string): string;
|
|
113
|
-
declare function cosineNormalized(a: Float32Array, b: Float32Array): number;
|
|
114
|
-
declare function rankContentGaps(queries: ContentGapQueryCandidate[], urls: string[], queryEmbeddings: Float32Array[], urlEmbeddings: Float32Array[], minDivergence: number): ContentGapResult[];
|
|
115
|
-
declare function createContentGapAnalyzer(opts?: CreateContentGapAnalyzerOptions): ContentGapAnalyzer;
|
|
116
|
-
declare function analyzeContentGap(source: AnalysisQuerySource, opts?: ContentGapOptions): Promise<ContentGapAnalysis>;
|
|
117
|
-
export { type ContentGapAnalysis, type ContentGapAnalyzer, type ContentGapEmbeddingCache, type ContentGapEmbeddingRuntime, type ContentGapOptions, type ContentGapProgress, type ContentGapResult, ContentGapSourceUnsupportedError, type CreateContentGapAnalyzerOptions, analyzeContentGap, cosineNormalized, createContentGapAnalyzer, createMemoryContentGapCache, deriveUrlText, normalizeUrl, rankContentGaps };
|
package/dist/semantic/index.mjs
DELETED
|
@@ -1,444 +0,0 @@
|
|
|
1
|
-
const CONTENT_GAP_MODEL_ID = "Xenova/bge-base-en-v1.5";
|
|
2
|
-
const CONTENT_GAP_QUERY_PREFIX = "Represent this sentence for searching relevant passages: ";
|
|
3
|
-
const DB_NAME = "content-gap-embeddings";
|
|
4
|
-
const STORE = "vectors";
|
|
5
|
-
function cacheKey(role, text) {
|
|
6
|
-
return `${CONTENT_GAP_MODEL_ID}|${role}|${text}`;
|
|
7
|
-
}
|
|
8
|
-
function createIndexedDbContentGapCache() {
|
|
9
|
-
let dbPromise = null;
|
|
10
|
-
function openDb() {
|
|
11
|
-
if (dbPromise != null) return dbPromise;
|
|
12
|
-
dbPromise = new Promise((resolve, reject) => {
|
|
13
|
-
const req = indexedDB.open(DB_NAME, 1);
|
|
14
|
-
req.onupgradeneeded = () => {
|
|
15
|
-
const db = req.result;
|
|
16
|
-
if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
|
|
17
|
-
};
|
|
18
|
-
req.onsuccess = () => resolve(req.result);
|
|
19
|
-
req.onerror = () => reject(req.error ?? /* @__PURE__ */ new Error("indexedDB open failed"));
|
|
20
|
-
});
|
|
21
|
-
return dbPromise;
|
|
22
|
-
}
|
|
23
|
-
return {
|
|
24
|
-
async getMany(role, texts) {
|
|
25
|
-
const db = await openDb().catch(() => null);
|
|
26
|
-
if (db == null) return /* @__PURE__ */ new Map();
|
|
27
|
-
return new Promise((resolve) => {
|
|
28
|
-
const store = db.transaction(STORE, "readonly").objectStore(STORE);
|
|
29
|
-
const out = /* @__PURE__ */ new Map();
|
|
30
|
-
let pending = texts.length;
|
|
31
|
-
if (pending === 0) {
|
|
32
|
-
resolve(out);
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
for (const text of texts) {
|
|
36
|
-
const req = store.get(cacheKey(role, text));
|
|
37
|
-
req.onsuccess = () => {
|
|
38
|
-
if (req.result instanceof Float32Array) out.set(text, req.result);
|
|
39
|
-
pending -= 1;
|
|
40
|
-
if (pending === 0) resolve(out);
|
|
41
|
-
};
|
|
42
|
-
req.onerror = () => {
|
|
43
|
-
pending -= 1;
|
|
44
|
-
if (pending === 0) resolve(out);
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
});
|
|
48
|
-
},
|
|
49
|
-
async putMany(role, entries) {
|
|
50
|
-
const db = await openDb().catch(() => null);
|
|
51
|
-
if (db == null) return;
|
|
52
|
-
await new Promise((resolve) => {
|
|
53
|
-
const tx = db.transaction(STORE, "readwrite");
|
|
54
|
-
for (const [text, vector] of entries) tx.objectStore(STORE).put(vector, cacheKey(role, text));
|
|
55
|
-
tx.oncomplete = () => resolve();
|
|
56
|
-
tx.onerror = () => resolve();
|
|
57
|
-
tx.onabort = () => resolve();
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
function createMemoryContentGapCache() {
|
|
63
|
-
const values = /* @__PURE__ */ new Map();
|
|
64
|
-
return {
|
|
65
|
-
async getMany(role, texts) {
|
|
66
|
-
const out = /* @__PURE__ */ new Map();
|
|
67
|
-
for (const text of texts) {
|
|
68
|
-
const value = values.get(cacheKey(role, text));
|
|
69
|
-
if (value) out.set(text, value);
|
|
70
|
-
}
|
|
71
|
-
return out;
|
|
72
|
-
},
|
|
73
|
-
async putMany(role, entries) {
|
|
74
|
-
for (const [text, vector] of entries) values.set(cacheKey(role, text), vector);
|
|
75
|
-
}
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
async function embedRawBatch(extractor, texts, onProgress, batchSize = 32) {
|
|
79
|
-
const result = [];
|
|
80
|
-
for (let i = 0; i < texts.length; i += batchSize) {
|
|
81
|
-
const batch = texts.slice(i, i + batchSize);
|
|
82
|
-
const out = await extractor(batch, {
|
|
83
|
-
pooling: "mean",
|
|
84
|
-
normalize: true
|
|
85
|
-
});
|
|
86
|
-
const dim = out.dims[out.dims.length - 1];
|
|
87
|
-
for (let k = 0; k < batch.length; k++) {
|
|
88
|
-
const start = k * dim;
|
|
89
|
-
result.push(new Float32Array(out.data.buffer, out.data.byteOffset + start * 4, dim).slice());
|
|
90
|
-
}
|
|
91
|
-
onProgress(result.length);
|
|
92
|
-
}
|
|
93
|
-
return result;
|
|
94
|
-
}
|
|
95
|
-
async function selectContentGapDevice(requested) {
|
|
96
|
-
let chosenDevice = "wasm";
|
|
97
|
-
if (requested === "webgpu" || requested == null) {
|
|
98
|
-
const gpu = globalThis.navigator?.gpu;
|
|
99
|
-
if (gpu != null) {
|
|
100
|
-
if (await gpu.requestAdapter().catch(() => null) != null) chosenDevice = "webgpu";
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
return chosenDevice;
|
|
104
|
-
}
|
|
105
|
-
async function loadContentGapExtractor(device) {
|
|
106
|
-
const { pipeline, env } = await import("@huggingface/transformers");
|
|
107
|
-
env.useBrowserCache = true;
|
|
108
|
-
return await pipeline("feature-extraction", CONTENT_GAP_MODEL_ID, {
|
|
109
|
-
device,
|
|
110
|
-
dtype: "fp32"
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
async function embedContentGapTexts(extractor, role, texts, transform, onProgress, cache) {
|
|
114
|
-
const cached = await cache.getMany(role, texts);
|
|
115
|
-
const vectors = Array.from({ length: texts.length });
|
|
116
|
-
const missIdx = [];
|
|
117
|
-
const missTexts = [];
|
|
118
|
-
for (let i = 0; i < texts.length; i++) {
|
|
119
|
-
const hit = cached.get(texts[i]);
|
|
120
|
-
if (hit != null) vectors[i] = hit;
|
|
121
|
-
else {
|
|
122
|
-
missIdx.push(i);
|
|
123
|
-
missTexts.push(transform(texts[i]));
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
const hits = cached.size;
|
|
127
|
-
const misses = missTexts.length;
|
|
128
|
-
onProgress(hits, texts.length);
|
|
129
|
-
if (missTexts.length > 0) {
|
|
130
|
-
const embedded = await embedRawBatch(extractor, missTexts, (done) => {
|
|
131
|
-
onProgress(hits + done, texts.length);
|
|
132
|
-
});
|
|
133
|
-
const toPersist = [];
|
|
134
|
-
for (let m = 0; m < embedded.length; m++) {
|
|
135
|
-
const i = missIdx[m];
|
|
136
|
-
vectors[i] = embedded[m];
|
|
137
|
-
toPersist.push([texts[i], embedded[m]]);
|
|
138
|
-
}
|
|
139
|
-
await cache.putMany(role, toPersist);
|
|
140
|
-
}
|
|
141
|
-
return {
|
|
142
|
-
vectors,
|
|
143
|
-
hits,
|
|
144
|
-
misses
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
function createContentGapEmbeddingRuntime(cache = createIndexedDbContentGapCache()) {
|
|
148
|
-
return {
|
|
149
|
-
modelId: CONTENT_GAP_MODEL_ID,
|
|
150
|
-
queryPrefix: CONTENT_GAP_QUERY_PREFIX,
|
|
151
|
-
selectDevice: selectContentGapDevice,
|
|
152
|
-
loadExtractor: loadContentGapExtractor,
|
|
153
|
-
embed(extractor, role, texts, transform, onProgress) {
|
|
154
|
-
return embedContentGapTexts(extractor, role, texts, transform, onProgress, cache);
|
|
155
|
-
}
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
async function fetchContentGapInputs(executeSql, options, normalizeUrl, now = () => performance.now()) {
|
|
159
|
-
const t1 = now();
|
|
160
|
-
const queryRows = await executeSql(`
|
|
161
|
-
WITH query_totals AS (
|
|
162
|
-
SELECT query,
|
|
163
|
-
SUM(impressions)::BIGINT AS total_impressions,
|
|
164
|
-
SUM(clicks)::BIGINT AS total_clicks,
|
|
165
|
-
SUM(sum_position) / NULLIF(SUM(impressions), 0) + 1 AS avg_position
|
|
166
|
-
FROM main.page_queries
|
|
167
|
-
WHERE query IS NOT NULL AND query <> ''
|
|
168
|
-
GROUP BY query
|
|
169
|
-
HAVING SUM(impressions) >= ?
|
|
170
|
-
ORDER BY total_impressions DESC
|
|
171
|
-
LIMIT ?
|
|
172
|
-
),
|
|
173
|
-
per_query_url AS (
|
|
174
|
-
SELECT pk.query, pk.url,
|
|
175
|
-
SUM(pk.impressions)::BIGINT AS url_impressions,
|
|
176
|
-
SUM(pk.sum_position) / NULLIF(SUM(pk.impressions), 0) + 1 AS url_position,
|
|
177
|
-
ROW_NUMBER() OVER (PARTITION BY pk.query ORDER BY SUM(pk.impressions) DESC) AS rnk
|
|
178
|
-
FROM main.page_queries pk
|
|
179
|
-
JOIN query_totals qt USING (query)
|
|
180
|
-
WHERE pk.url IS NOT NULL AND pk.url <> ''
|
|
181
|
-
GROUP BY pk.query, pk.url
|
|
182
|
-
)
|
|
183
|
-
SELECT q.query, q.total_impressions AS impressions, q.total_clicks AS clicks, q.avg_position,
|
|
184
|
-
pu.url AS current_url, pu.url_position AS current_position
|
|
185
|
-
FROM query_totals q
|
|
186
|
-
JOIN per_query_url pu USING (query)
|
|
187
|
-
WHERE pu.rnk = 1
|
|
188
|
-
`, [Number(options.minImpressions), Number(options.maxQueries)]);
|
|
189
|
-
const urlRows = await executeSql(`
|
|
190
|
-
SELECT url, SUM(impressions)::BIGINT AS impressions
|
|
191
|
-
FROM main.page_queries
|
|
192
|
-
WHERE url IS NOT NULL AND url <> ''
|
|
193
|
-
GROUP BY url
|
|
194
|
-
ORDER BY impressions DESC
|
|
195
|
-
LIMIT ?
|
|
196
|
-
`, [Number(options.maxUrls)]);
|
|
197
|
-
const sqlMs = now() - t1;
|
|
198
|
-
const queries = queryRows.map((row) => ({
|
|
199
|
-
query: String(row.query),
|
|
200
|
-
impressions: Number(row.impressions),
|
|
201
|
-
clicks: Number(row.clicks),
|
|
202
|
-
avgPosition: Number(row.avg_position),
|
|
203
|
-
currentUrl: normalizeUrl(String(row.current_url))
|
|
204
|
-
}));
|
|
205
|
-
const urlAgg = /* @__PURE__ */ new Map();
|
|
206
|
-
for (const row of urlRows) {
|
|
207
|
-
const norm = normalizeUrl(String(row.url));
|
|
208
|
-
urlAgg.set(norm, (urlAgg.get(norm) ?? 0) + Number(row.impressions));
|
|
209
|
-
}
|
|
210
|
-
return {
|
|
211
|
-
queries,
|
|
212
|
-
urls: [...urlAgg.entries()].sort((a, b) => b[1] - a[1]).slice(0, Number(options.maxUrls)).map(([url]) => url),
|
|
213
|
-
sqlMs
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
var ContentGapSourceUnsupportedError = class extends Error {
|
|
217
|
-
constructor(kind) {
|
|
218
|
-
super(`content-gap requires a source with executeSql (got '${kind}'); use createBrowserQuerySource or createSqliteQuerySource`);
|
|
219
|
-
this.name = "ContentGapSourceUnsupportedError";
|
|
220
|
-
}
|
|
221
|
-
};
|
|
222
|
-
const ORIGIN_RE = /^https?:\/\/[^/]+/;
|
|
223
|
-
const HTML_EXT_RE = /\.(html?|php|aspx?)$/i;
|
|
224
|
-
const SEP_RE = /[-_]+/g;
|
|
225
|
-
const DIGITS_ONLY_RE = /^\d+$/;
|
|
226
|
-
const WWW_RE = /^www\./;
|
|
227
|
-
const DOT_RE = /\./g;
|
|
228
|
-
const HASH_RE = /#.*$/;
|
|
229
|
-
const QUERY_RE = /\?.*$/;
|
|
230
|
-
const TRAIL_SLASH_RE = /(?<=.)\/$/;
|
|
231
|
-
function normalizeUrl(u) {
|
|
232
|
-
try {
|
|
233
|
-
const url = new URL(u);
|
|
234
|
-
url.hash = "";
|
|
235
|
-
url.search = "";
|
|
236
|
-
let path = url.pathname;
|
|
237
|
-
if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
|
|
238
|
-
return `${url.origin}${path}`;
|
|
239
|
-
} catch {
|
|
240
|
-
return u.replace(HASH_RE, "").replace(QUERY_RE, "").replace(TRAIL_SLASH_RE, "");
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
function deriveUrlText(url) {
|
|
244
|
-
try {
|
|
245
|
-
const u = new URL(url);
|
|
246
|
-
const text = u.pathname.split("/").filter(Boolean).map((s) => decodeURIComponent(s).toLowerCase()).map((s) => s.replace(HTML_EXT_RE, "")).map((s) => s.replace(SEP_RE, " ")).filter((s) => s.length > 0 && !DIGITS_ONLY_RE.test(s)).join(" ");
|
|
247
|
-
if (text.length > 0) return text;
|
|
248
|
-
return u.hostname.replace(WWW_RE, "").replace(DOT_RE, " ");
|
|
249
|
-
} catch {
|
|
250
|
-
return url.replace(ORIGIN_RE, "").replace(SEP_RE, " ");
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
function cosineNormalized(a, b) {
|
|
254
|
-
let dot = 0;
|
|
255
|
-
const n = a.length;
|
|
256
|
-
for (let i = 0; i < n; i++) dot += a[i] * b[i];
|
|
257
|
-
return dot;
|
|
258
|
-
}
|
|
259
|
-
function notify(onProgress, progress) {
|
|
260
|
-
onProgress?.(progress);
|
|
261
|
-
}
|
|
262
|
-
function rankContentGaps(queries, urls, queryEmbeddings, urlEmbeddings, minDivergence) {
|
|
263
|
-
const urlIndex = /* @__PURE__ */ new Map();
|
|
264
|
-
for (let i = 0; i < urls.length; i++) urlIndex.set(urls[i], i);
|
|
265
|
-
const gaps = [];
|
|
266
|
-
for (let i = 0; i < queries.length; i++) {
|
|
267
|
-
const qr = queries[i];
|
|
268
|
-
const qEmb = queryEmbeddings[i];
|
|
269
|
-
const currentIdx = urlIndex.get(qr.currentUrl);
|
|
270
|
-
const top = [];
|
|
271
|
-
let currentSimilarity = 0;
|
|
272
|
-
for (let j = 0; j < urls.length; j++) {
|
|
273
|
-
const similarity = cosineNormalized(qEmb, urlEmbeddings[j]);
|
|
274
|
-
if (j === currentIdx) currentSimilarity = similarity;
|
|
275
|
-
let insertAt = top.length;
|
|
276
|
-
while (insertAt > 0 && similarity > top[insertAt - 1].similarity) insertAt--;
|
|
277
|
-
if (insertAt < 4) {
|
|
278
|
-
top.splice(insertAt, 0, {
|
|
279
|
-
url: urls[j],
|
|
280
|
-
similarity
|
|
281
|
-
});
|
|
282
|
-
if (top.length > 4) top.pop();
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
const suggested = top[0];
|
|
286
|
-
if (!suggested) continue;
|
|
287
|
-
const suggestedUrl = suggested.url;
|
|
288
|
-
const suggestedSimilarity = suggested.similarity;
|
|
289
|
-
if (suggestedUrl === qr.currentUrl) continue;
|
|
290
|
-
const divergence = suggestedSimilarity - currentSimilarity;
|
|
291
|
-
if (divergence < minDivergence) continue;
|
|
292
|
-
gaps.push({
|
|
293
|
-
query: qr.query,
|
|
294
|
-
impressions: qr.impressions,
|
|
295
|
-
clicks: qr.clicks,
|
|
296
|
-
avgPosition: qr.avgPosition,
|
|
297
|
-
currentUrl: qr.currentUrl,
|
|
298
|
-
currentSimilarity,
|
|
299
|
-
suggestedUrl,
|
|
300
|
-
suggestedSimilarity,
|
|
301
|
-
alternatives: top.slice(1),
|
|
302
|
-
divergence,
|
|
303
|
-
impact: qr.impressions * divergence
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
gaps.sort((a, b) => b.impact - a.impact);
|
|
307
|
-
return gaps;
|
|
308
|
-
}
|
|
309
|
-
async function runContentGapAnalysis(source, opts = {}, runtime) {
|
|
310
|
-
if (!source.executeSql) throw new ContentGapSourceUnsupportedError(source.name ?? "unknown");
|
|
311
|
-
const executeSql = source.executeSql.bind(source);
|
|
312
|
-
const { maxQueries = 1500, maxUrls = 400, minImpressions = 50, minDivergence = .12, device, onProgress } = opts;
|
|
313
|
-
notify(onProgress, {
|
|
314
|
-
phase: "loading-model",
|
|
315
|
-
message: "Checking device..."
|
|
316
|
-
});
|
|
317
|
-
const t0 = runtime.now();
|
|
318
|
-
const chosenDevice = await runtime.embeddings.selectDevice(device);
|
|
319
|
-
notify(onProgress, {
|
|
320
|
-
phase: "loading-model",
|
|
321
|
-
message: `Loading ${runtime.embeddings.modelId} on ${chosenDevice} (~110MB, cached after first run)...`
|
|
322
|
-
});
|
|
323
|
-
const extractor = await runtime.embeddings.loadExtractor(chosenDevice);
|
|
324
|
-
const modelMs = runtime.now() - t0;
|
|
325
|
-
notify(onProgress, {
|
|
326
|
-
phase: "fetching-data",
|
|
327
|
-
message: `Running SQL (device: ${chosenDevice})...`,
|
|
328
|
-
modelMs
|
|
329
|
-
});
|
|
330
|
-
const { queries, urls, sqlMs } = await runtime.loadInputs(executeSql, {
|
|
331
|
-
maxQueries,
|
|
332
|
-
maxUrls,
|
|
333
|
-
minImpressions
|
|
334
|
-
}, normalizeUrl, runtime.now);
|
|
335
|
-
if (queries.length === 0 || urls.length === 0) {
|
|
336
|
-
notify(onProgress, {
|
|
337
|
-
phase: "done",
|
|
338
|
-
message: "Not enough data to analyze.",
|
|
339
|
-
modelMs,
|
|
340
|
-
sqlMs
|
|
341
|
-
});
|
|
342
|
-
return {
|
|
343
|
-
results: [],
|
|
344
|
-
meta: {
|
|
345
|
-
modelMs,
|
|
346
|
-
sqlMs,
|
|
347
|
-
embedMs: 0,
|
|
348
|
-
computeMs: 0,
|
|
349
|
-
cacheHits: 0,
|
|
350
|
-
totalInputs: 0,
|
|
351
|
-
device: chosenDevice,
|
|
352
|
-
modelId: runtime.embeddings.modelId
|
|
353
|
-
}
|
|
354
|
-
};
|
|
355
|
-
}
|
|
356
|
-
const queryTexts = queries.map((q) => q.query);
|
|
357
|
-
const urlTexts = urls.map(deriveUrlText);
|
|
358
|
-
notify(onProgress, {
|
|
359
|
-
phase: "embedding-queries",
|
|
360
|
-
message: `Embedding ${queryTexts.length} queries on ${chosenDevice}...`,
|
|
361
|
-
total: queryTexts.length,
|
|
362
|
-
done: 0,
|
|
363
|
-
modelMs,
|
|
364
|
-
sqlMs
|
|
365
|
-
});
|
|
366
|
-
const t2 = runtime.now();
|
|
367
|
-
const queryEmbed = await runtime.embeddings.embed(extractor, "query", queryTexts, (t) => runtime.embeddings.queryPrefix + t, (done, total) => {
|
|
368
|
-
notify(onProgress, {
|
|
369
|
-
phase: "embedding-queries",
|
|
370
|
-
message: `Embedding ${queryTexts.length} queries on ${chosenDevice}...`,
|
|
371
|
-
done,
|
|
372
|
-
total,
|
|
373
|
-
modelMs,
|
|
374
|
-
sqlMs
|
|
375
|
-
});
|
|
376
|
-
});
|
|
377
|
-
notify(onProgress, {
|
|
378
|
-
phase: "embedding-urls",
|
|
379
|
-
message: `Embedding ${urls.length} URLs...`,
|
|
380
|
-
total: urls.length,
|
|
381
|
-
done: 0,
|
|
382
|
-
modelMs,
|
|
383
|
-
sqlMs
|
|
384
|
-
});
|
|
385
|
-
const urlEmbed = await runtime.embeddings.embed(extractor, "passage", urlTexts, (t) => t, (done, total) => {
|
|
386
|
-
notify(onProgress, {
|
|
387
|
-
phase: "embedding-urls",
|
|
388
|
-
message: `Embedding ${urls.length} URLs...`,
|
|
389
|
-
done,
|
|
390
|
-
total,
|
|
391
|
-
modelMs,
|
|
392
|
-
sqlMs
|
|
393
|
-
});
|
|
394
|
-
});
|
|
395
|
-
const embedMs = runtime.now() - t2;
|
|
396
|
-
notify(onProgress, {
|
|
397
|
-
phase: "computing-gaps",
|
|
398
|
-
message: "Computing semantic similarities...",
|
|
399
|
-
modelMs,
|
|
400
|
-
sqlMs,
|
|
401
|
-
embedMs
|
|
402
|
-
});
|
|
403
|
-
const t3 = runtime.now();
|
|
404
|
-
const gaps = rankContentGaps(queries, urls, queryEmbed.vectors, urlEmbed.vectors, minDivergence);
|
|
405
|
-
const computeMs = runtime.now() - t3;
|
|
406
|
-
const totalHits = queryEmbed.hits + urlEmbed.hits;
|
|
407
|
-
const totalInputs = queryTexts.length + urls.length;
|
|
408
|
-
const cacheNote = totalHits > 0 ? ` · ${totalHits}/${totalInputs} cache hits` : "";
|
|
409
|
-
notify(onProgress, {
|
|
410
|
-
phase: "done",
|
|
411
|
-
message: `Found ${gaps.length} content gaps across ${queries.length} queries${cacheNote}`,
|
|
412
|
-
modelMs,
|
|
413
|
-
sqlMs,
|
|
414
|
-
embedMs,
|
|
415
|
-
computeMs
|
|
416
|
-
});
|
|
417
|
-
return {
|
|
418
|
-
results: gaps.slice(0, 150),
|
|
419
|
-
meta: {
|
|
420
|
-
modelMs,
|
|
421
|
-
sqlMs,
|
|
422
|
-
embedMs,
|
|
423
|
-
computeMs,
|
|
424
|
-
cacheHits: totalHits,
|
|
425
|
-
totalInputs,
|
|
426
|
-
device: chosenDevice,
|
|
427
|
-
modelId: runtime.embeddings.modelId
|
|
428
|
-
}
|
|
429
|
-
};
|
|
430
|
-
}
|
|
431
|
-
function createContentGapAnalyzer(opts = {}) {
|
|
432
|
-
const runtime = {
|
|
433
|
-
embeddings: opts.embeddings ?? createContentGapEmbeddingRuntime(),
|
|
434
|
-
loadInputs: opts.loadInputs ?? fetchContentGapInputs,
|
|
435
|
-
now: opts.now ?? (() => performance.now())
|
|
436
|
-
};
|
|
437
|
-
return { analyze(source, options) {
|
|
438
|
-
return runContentGapAnalysis(source, options, runtime);
|
|
439
|
-
} };
|
|
440
|
-
}
|
|
441
|
-
function analyzeContentGap(source, opts = {}) {
|
|
442
|
-
return createContentGapAnalyzer().analyze(source, opts);
|
|
443
|
-
}
|
|
444
|
-
export { ContentGapSourceUnsupportedError, analyzeContentGap, cosineNormalized, createContentGapAnalyzer, createMemoryContentGapCache, deriveUrlText, normalizeUrl, rankContentGaps };
|
package/dist/source/index.d.mts
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import { BuilderState } from "gscdump/query";
|
|
2
|
-
import { PlannerCapabilities } from "gscdump/query/plan";
|
|
3
|
-
import { AnalysisQuerySource, QueryRow } from "@gscdump/engine/source";
|
|
4
|
-
interface SyncedRange {
|
|
5
|
-
oldestDateSynced: string | null;
|
|
6
|
-
newestDateSynced: string | null;
|
|
7
|
-
/**
|
|
8
|
-
* Optional sorted list of `[start, end]` daily-key spans (`YYYY-MM-DD`,
|
|
9
|
-
* both inclusive) that the engine actually has partitions for. When set,
|
|
10
|
-
* `shouldRouteToLive` returns true for any requested range that overlaps
|
|
11
|
-
* a day NOT inside one of these spans — even when the request sits inside
|
|
12
|
-
* `oldestDateSynced..newestDateSynced`. Lets the composite catch *internal*
|
|
13
|
-
* manifest gaps (e.g. a missing monthly tier) that the outer envelope
|
|
14
|
-
* doesn't reveal. Spans must be sorted by `start` and non-overlapping.
|
|
15
|
-
*/
|
|
16
|
-
coveredSpans?: ReadonlyArray<{
|
|
17
|
-
start: string;
|
|
18
|
-
end: string;
|
|
19
|
-
}>;
|
|
20
|
-
}
|
|
21
|
-
interface CompositeSourceOptions {
|
|
22
|
-
engine: AnalysisQuerySource;
|
|
23
|
-
live: AnalysisQuerySource;
|
|
24
|
-
site: SyncedRange;
|
|
25
|
-
}
|
|
26
|
-
declare function createCompositeSource(opts: CompositeSourceOptions): AnalysisQuerySource;
|
|
27
|
-
/**
|
|
28
|
-
* Permissive defaults: in-memory sources are usually test doubles, so they
|
|
29
|
-
* advertise every capability unless the test explicitly narrows them.
|
|
30
|
-
*/
|
|
31
|
-
declare const IN_MEMORY_DEFAULT_CAPABILITIES: PlannerCapabilities;
|
|
32
|
-
interface InMemoryQuerySourceOptions {
|
|
33
|
-
queryRows: (state: BuilderState) => Promise<QueryRow[]> | QueryRow[];
|
|
34
|
-
capabilities?: PlannerCapabilities;
|
|
35
|
-
}
|
|
36
|
-
declare function createInMemoryQuerySource(options: InMemoryQuerySourceOptions): AnalysisQuerySource;
|
|
37
|
-
export { type CompositeSourceOptions, IN_MEMORY_DEFAULT_CAPABILITIES, type InMemoryQuerySourceOptions, type SyncedRange, createCompositeSource, createInMemoryQuerySource };
|
package/dist/source/index.mjs
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { canProxyToGsc } from "@gscdump/engine-gsc-api";
|
|
2
|
-
import { extractDateRange } from "gscdump/query";
|
|
3
|
-
import { isStateResolvable } from "gscdump/query/plan";
|
|
4
|
-
function hasGapInCoveredSpans(start, end, coveredSpans) {
|
|
5
|
-
let cursor = start;
|
|
6
|
-
for (const span of coveredSpans) {
|
|
7
|
-
if (span.end < cursor) continue;
|
|
8
|
-
if (span.start > cursor) return true;
|
|
9
|
-
if (span.end >= end) return false;
|
|
10
|
-
cursor = nextDay(span.end);
|
|
11
|
-
if (cursor > end) return false;
|
|
12
|
-
}
|
|
13
|
-
return cursor <= end;
|
|
14
|
-
}
|
|
15
|
-
function nextDay(day) {
|
|
16
|
-
const t = Date.parse(`${day}T00:00:00Z`) + 864e5;
|
|
17
|
-
return new Date(t).toISOString().slice(0, 10);
|
|
18
|
-
}
|
|
19
|
-
function shouldRouteToLive(state, site) {
|
|
20
|
-
if (!canProxyToGsc(state)) return false;
|
|
21
|
-
if (!isStateResolvable(state)) return true;
|
|
22
|
-
const { startDate, endDate } = extractDateRange(state.filter);
|
|
23
|
-
if (!startDate || !endDate) return false;
|
|
24
|
-
if (!site.oldestDateSynced || !site.newestDateSynced) return true;
|
|
25
|
-
if (startDate < site.oldestDateSynced || endDate > site.newestDateSynced) return true;
|
|
26
|
-
if (site.coveredSpans && site.coveredSpans.length > 0) return hasGapInCoveredSpans(startDate, endDate, site.coveredSpans);
|
|
27
|
-
return false;
|
|
28
|
-
}
|
|
29
|
-
function createCompositeSource(opts) {
|
|
30
|
-
const { engine, live, site } = opts;
|
|
31
|
-
const engineExecuteSql = engine.executeSql;
|
|
32
|
-
return {
|
|
33
|
-
name: "composite-engine-live",
|
|
34
|
-
kind: "composite",
|
|
35
|
-
capabilities: engine.capabilities,
|
|
36
|
-
adapter: engine.adapter,
|
|
37
|
-
siteId: engine.siteId,
|
|
38
|
-
async queryRows(state) {
|
|
39
|
-
return shouldRouteToLive(state, site) ? live.queryRows(state) : engine.queryRows(state);
|
|
40
|
-
},
|
|
41
|
-
executeSql: engineExecuteSql ? (sql, params, execOpts) => engineExecuteSql(sql, params, execOpts) : void 0
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
const IN_MEMORY_DEFAULT_CAPABILITIES = {
|
|
45
|
-
regex: true,
|
|
46
|
-
multiDataset: true,
|
|
47
|
-
comparisonJoin: true,
|
|
48
|
-
windowTotals: true
|
|
49
|
-
};
|
|
50
|
-
function createInMemoryQuerySource(options) {
|
|
51
|
-
return {
|
|
52
|
-
name: "memory",
|
|
53
|
-
kind: "in-memory",
|
|
54
|
-
capabilities: options.capabilities ?? IN_MEMORY_DEFAULT_CAPABILITIES,
|
|
55
|
-
async queryRows(state) {
|
|
56
|
-
return await options.queryRows(state);
|
|
57
|
-
}
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
export { IN_MEMORY_DEFAULT_CAPABILITIES, createCompositeSource, createInMemoryQuerySource };
|