crawlforge-mcp-server 6.24.0 → 6.25.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/package.json +4 -4
- package/server.js +2 -2
- package/src/core/ActionExecutor.js +14 -0
- package/src/schemas/toolOutputSchemas.js +1 -1
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-brand-style-guide/SKILL.md +107 -0
- package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +2 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +1 -0
- package/src/tools/scrape/_brandingColor.js +185 -0
- package/src/tools/scrape/_brandingComputed.js +262 -0
- package/src/tools/scrape/_brandingExtractor.js +62 -90
- package/src/tools/scrape/_brandingTokens.js +350 -0
- package/src/tools/scrape/unifiedScrape.js +57 -16
- package/src/tools/search/adapters/dataforseoSearch.js +70 -8
- package/src/tools/search/queryExpander.js +16 -1
- package/src/tools/search/serpRank.js +22 -9
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* _brandingTokens.js — design tokens from a page's CSS custom properties.
|
|
3
|
+
*
|
|
4
|
+
* resolveValue follows var() chains (with fallbacks) to a literal value
|
|
5
|
+
* groupTokens sorts the resolved variables into colour ramps, semantic
|
|
6
|
+
* colours, type, spacing, radius and shadow, drops what is
|
|
7
|
+
* not a design token (layout state such as --bar-index), and
|
|
8
|
+
* caps each group so a design-system site stays readable
|
|
9
|
+
* toDtcg writes the groups as W3C Design Tokens (DTCG 2025.10) JSON
|
|
10
|
+
*
|
|
11
|
+
* stats in the grouped result say how many variables each step dropped, so a
|
|
12
|
+
* caller can tell a small design system from a capped one.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { parseColor, toHex, toHexAlpha } from './_brandingColor.js';
|
|
16
|
+
|
|
17
|
+
const MAX_DEPTH = 16;
|
|
18
|
+
|
|
19
|
+
const CAPS = { ramps: 24, steps: 24, semantic: 40, typography: 32, spacing: 40, radius: 16, shadow: 16 };
|
|
20
|
+
|
|
21
|
+
// Names that say a variable is page or animation state, not a design token.
|
|
22
|
+
const NOISE_RE = /(index|total|count|scale|aspect|ratio|progress|duration|delay|ease|opacity|columns?|rows?|translate|rotate|angle|fallback|graphic|z-)/i;
|
|
23
|
+
const TYPE_NAME_RE = /(font|text|heading|display|body|leading|tracking|letter-?spacing|line-?height|weight|typo)/i;
|
|
24
|
+
const SPACE_NAME_RE = /(space|spacing|gap|gutter|padding|margin|inset|stack)/i;
|
|
25
|
+
// Semantic colours whose names say what they are for are kept first.
|
|
26
|
+
const ROLE_NAME_RE = /(primary|secondary|brand|accent|background|surface|foreground|text|link|border)/i;
|
|
27
|
+
|
|
28
|
+
const LENGTH_RE = /^-?(?:\d+\.?\d*|\.\d+)(?:px|rem|em)$|^0$/;
|
|
29
|
+
const NUMBER_RE = /^-?(?:\d+\.?\d*|\.\d+)$/;
|
|
30
|
+
|
|
31
|
+
const TYPE_PROPS = [
|
|
32
|
+
[/-?-(?:font-?)?size$/, 'size'],
|
|
33
|
+
[/-?-(?:line-?height|leading)$/, 'lineHeight'],
|
|
34
|
+
[/-?-(?:font-?)?weight$/, 'weight'],
|
|
35
|
+
[/-?-(?:letter-?spacing|tracking)$/, 'letterSpacing'],
|
|
36
|
+
[/-?-(?:font-?)?family$/, 'family'],
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// Split on commas (or on whitespace, when `ws`) that are outside parentheses.
|
|
40
|
+
function splitTopLevel(value, ws = false) {
|
|
41
|
+
const parts = [];
|
|
42
|
+
let depth = 0;
|
|
43
|
+
let cur = '';
|
|
44
|
+
for (const ch of value) {
|
|
45
|
+
if (ch === '(') depth++;
|
|
46
|
+
else if (ch === ')') depth = Math.max(0, depth - 1);
|
|
47
|
+
if (depth === 0 && (ws ? /\s/.test(ch) : ch === ',')) {
|
|
48
|
+
parts.push(cur);
|
|
49
|
+
cur = '';
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
cur += ch;
|
|
53
|
+
}
|
|
54
|
+
parts.push(cur);
|
|
55
|
+
return parts.map((p) => p.trim()).filter(Boolean);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The first var( reference at or after `from`, with its balanced inner text.
|
|
59
|
+
function findVar(value, from) {
|
|
60
|
+
const start = value.indexOf('var(', from);
|
|
61
|
+
if (start < 0) return null;
|
|
62
|
+
let depth = 0;
|
|
63
|
+
for (let i = start + 3; i < value.length; i++) {
|
|
64
|
+
if (value[i] === '(') depth++;
|
|
65
|
+
else if (value[i] === ')' && --depth === 0) return { start, end: i + 1, inner: value.slice(start + 4, i) };
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Replace every var() in `value` with what it resolves to. Returns null when
|
|
72
|
+
* a reference has neither a definition nor a fallback, or the chain cycles.
|
|
73
|
+
* @param {string} value
|
|
74
|
+
* @param {Record<string, string>} vars - '--name' -> raw value
|
|
75
|
+
*/
|
|
76
|
+
export function resolveValue(value, vars, seen = new Set(), depth = 0) {
|
|
77
|
+
if (typeof value !== 'string' || depth > MAX_DEPTH) return null;
|
|
78
|
+
let out = '';
|
|
79
|
+
let cursor = 0;
|
|
80
|
+
let ref;
|
|
81
|
+
while ((ref = findVar(value, cursor))) {
|
|
82
|
+
out += value.slice(cursor, ref.start);
|
|
83
|
+
const comma = ref.inner.indexOf(',');
|
|
84
|
+
const name = (comma < 0 ? ref.inner : ref.inner.slice(0, comma)).trim();
|
|
85
|
+
const fallback = comma < 0 ? '' : ref.inner.slice(comma + 1).trim();
|
|
86
|
+
let resolved = null;
|
|
87
|
+
if (!seen.has(name) && vars[name] != null) {
|
|
88
|
+
resolved = resolveValue(vars[name], vars, new Set([...seen, name]), depth + 1);
|
|
89
|
+
}
|
|
90
|
+
if (resolved == null && fallback) resolved = resolveValue(fallback, vars, seen, depth + 1);
|
|
91
|
+
if (resolved == null) return null;
|
|
92
|
+
out += resolved;
|
|
93
|
+
cursor = ref.end;
|
|
94
|
+
}
|
|
95
|
+
return (out + value.slice(cursor)).replace(/\s+/g, ' ').trim();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const isLength = (v) => LENGTH_RE.test(v);
|
|
99
|
+
const isLengthList = (v) => v.split(/\s+/).every(isLength);
|
|
100
|
+
const isFontList = (name, v) => /(font|family)/i.test(name) && /[a-z]/i.test(v) && !isLength(v) && !NUMBER_RE.test(v) && !/[(){}]/.test(v);
|
|
101
|
+
|
|
102
|
+
function classify(name, value) {
|
|
103
|
+
if (/shadow|elevation/i.test(name)) return value !== 'none' && /\d/.test(value) ? 'shadow' : null;
|
|
104
|
+
if (/radius|rounded|corner/i.test(name)) return isLengthList(value) ? 'radius' : null;
|
|
105
|
+
if (parseColor(value)) return 'color';
|
|
106
|
+
if (NOISE_RE.test(name)) return null;
|
|
107
|
+
if (TYPE_NAME_RE.test(name) && (isLength(value) || NUMBER_RE.test(value) || isFontList(name, value))) return 'typography';
|
|
108
|
+
if (SPACE_NAME_RE.test(name) && isLength(value)) return 'spacing';
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// A type variable's group (stem) and which property of it the value sets.
|
|
113
|
+
function typeProp(name, value) {
|
|
114
|
+
const lower = name.toLowerCase();
|
|
115
|
+
for (const [re, prop] of TYPE_PROPS) {
|
|
116
|
+
const m = lower.match(re);
|
|
117
|
+
if (m) return { stem: name.slice(0, m.index).replace(/-+$/, ''), prop };
|
|
118
|
+
}
|
|
119
|
+
let prop = null;
|
|
120
|
+
if (isFontList(name, value)) prop = 'family';
|
|
121
|
+
else if (/weight/.test(lower)) prop = 'weight';
|
|
122
|
+
else if (/leading|line-?height/.test(lower)) prop = 'lineHeight';
|
|
123
|
+
else if (/tracking|letter/.test(lower)) prop = 'letterSpacing';
|
|
124
|
+
else if (isLength(value)) prop = 'size';
|
|
125
|
+
return prop ? { stem: name, prop } : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function px(value) {
|
|
129
|
+
const n = parseFloat(value);
|
|
130
|
+
return /r?em$/.test(value) ? n * 16 : n;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function stepOrder(a, b) {
|
|
134
|
+
return parseFloat(a) - parseFloat(b) || a.localeCompare(b);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Sort CSS custom properties into design-token groups.
|
|
139
|
+
* @param {Record<string, string>} vars - '--name' -> raw value, as extracted
|
|
140
|
+
* @returns {object} { colors: { ramps, semantic }, typography, spacing, radius, shadow, stats }
|
|
141
|
+
*/
|
|
142
|
+
export function groupTokens(vars) {
|
|
143
|
+
const stats = { variables: 0, kept: 0, unresolved: 0, notTokens: 0, overCap: 0 };
|
|
144
|
+
const rampSteps = new Map(); // group -> Map(step -> hex)
|
|
145
|
+
const colorEntries = [];
|
|
146
|
+
const typography = new Map();
|
|
147
|
+
const spacing = [];
|
|
148
|
+
const radius = [];
|
|
149
|
+
const shadow = [];
|
|
150
|
+
|
|
151
|
+
for (const [key, raw] of Object.entries(vars)) {
|
|
152
|
+
stats.variables++;
|
|
153
|
+
const value = resolveValue(raw, vars, new Set([key]));
|
|
154
|
+
if (value == null) { stats.unresolved++; continue; }
|
|
155
|
+
const name = key.replace(/^--/, '');
|
|
156
|
+
// --tw-* is Tailwind's per-element utility state (--tw-shadow, --tw-ring-color), not theme.
|
|
157
|
+
const kind = name.startsWith('tw-') ? null : classify(name, value);
|
|
158
|
+
if (!kind) { stats.notTokens++; continue; }
|
|
159
|
+
|
|
160
|
+
if (kind === 'color') {
|
|
161
|
+
colorEntries.push({ name, hex: toHexAlpha(parseColor(value)) });
|
|
162
|
+
} else if (kind === 'typography') {
|
|
163
|
+
const tp = typeProp(name, value);
|
|
164
|
+
if (!tp) { stats.notTokens++; continue; }
|
|
165
|
+
const entry = typography.get(tp.stem) || {};
|
|
166
|
+
entry[tp.prop] = value;
|
|
167
|
+
typography.set(tp.stem, entry);
|
|
168
|
+
} else if (kind === 'spacing') {
|
|
169
|
+
spacing.push([name, value]);
|
|
170
|
+
} else if (kind === 'radius') {
|
|
171
|
+
radius.push([name, value]);
|
|
172
|
+
} else {
|
|
173
|
+
shadow.push([name, value]);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// A name ending in a step number (brand-600, neutral-50A) belongs to a
|
|
178
|
+
// ramp; a ramp needs three steps, or its colours count as semantic.
|
|
179
|
+
for (const { name, hex } of colorEntries) {
|
|
180
|
+
const m = name.match(/^(.+?)-(\d+[a-z]?)$/i);
|
|
181
|
+
if (!m) continue;
|
|
182
|
+
if (!rampSteps.has(m[1])) rampSteps.set(m[1], new Map());
|
|
183
|
+
rampSteps.get(m[1]).set(m[2], hex);
|
|
184
|
+
}
|
|
185
|
+
const ramps = {};
|
|
186
|
+
const inRamp = new Set();
|
|
187
|
+
for (const [group, steps] of rampSteps) {
|
|
188
|
+
if (steps.size < 3) continue;
|
|
189
|
+
if (Object.keys(ramps).length >= CAPS.ramps) { stats.overCap += steps.size; for (const s of steps.keys()) inRamp.add(`${group}-${s}`); continue; }
|
|
190
|
+
const sorted = [...steps.keys()].sort(stepOrder);
|
|
191
|
+
stats.overCap += Math.max(0, sorted.length - CAPS.steps);
|
|
192
|
+
ramps[group] = Object.fromEntries(sorted.slice(0, CAPS.steps).map((s) => [s, steps.get(s)]));
|
|
193
|
+
for (const s of steps.keys()) inRamp.add(`${group}-${s}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const semanticAll = colorEntries
|
|
197
|
+
.filter(({ name }) => !inRamp.has(name))
|
|
198
|
+
.sort((a, b) => Number(ROLE_NAME_RE.test(b.name)) - Number(ROLE_NAME_RE.test(a.name)) || a.name.length - b.name.length);
|
|
199
|
+
stats.overCap += Math.max(0, semanticAll.length - CAPS.semantic);
|
|
200
|
+
const semantic = Object.fromEntries(semanticAll.slice(0, CAPS.semantic).map(({ name, hex }) => [name, hex]));
|
|
201
|
+
|
|
202
|
+
const capped = (entries, cap) => {
|
|
203
|
+
stats.overCap += Math.max(0, entries.length - cap);
|
|
204
|
+
return Object.fromEntries(entries.slice(0, cap));
|
|
205
|
+
};
|
|
206
|
+
spacing.sort((a, b) => px(a[1]) - px(b[1]));
|
|
207
|
+
|
|
208
|
+
const result = {
|
|
209
|
+
colors: { ramps, semantic },
|
|
210
|
+
typography: capped([...typography.entries()], CAPS.typography),
|
|
211
|
+
spacing: capped(spacing, CAPS.spacing),
|
|
212
|
+
radius: capped(radius, CAPS.radius),
|
|
213
|
+
shadow: capped(shadow, CAPS.shadow),
|
|
214
|
+
};
|
|
215
|
+
const count = (o) => Object.keys(o).length;
|
|
216
|
+
stats.kept = Object.values(ramps).reduce((n, r) => n + count(r), 0) + count(semantic) +
|
|
217
|
+
[...Object.values(result.typography)].reduce((n, t) => n + count(t), 0) +
|
|
218
|
+
count(result.spacing) + count(result.radius) + count(result.shadow);
|
|
219
|
+
result.stats = stats;
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── DTCG 2025.10 ─────────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
const round = (n) => Math.round(n * 10000) / 10000;
|
|
226
|
+
|
|
227
|
+
function dtcgColor(c) {
|
|
228
|
+
return {
|
|
229
|
+
colorSpace: 'srgb',
|
|
230
|
+
components: [c.r, c.g, c.b].map((x) => round(x / 255)),
|
|
231
|
+
// Alpha arrives through 8-bit hex (1/255 steps), so two places is all it holds.
|
|
232
|
+
...(c.a < 1 ? { alpha: Math.round(c.a * 100) / 100 } : {}),
|
|
233
|
+
hex: toHex(c),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// DTCG dimensions are px or rem only; em and % have no equivalent.
|
|
238
|
+
function dtcgDimension(value) {
|
|
239
|
+
if (value === '0') return { value: 0, unit: 'px' };
|
|
240
|
+
const m = String(value).match(/^(-?(?:\d+\.?\d*|\.\d+))(px|rem)$/);
|
|
241
|
+
return m ? { value: parseFloat(m[1]), unit: m[2] } : null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function dtcgShadow(value) {
|
|
245
|
+
const layers = [];
|
|
246
|
+
for (const layer of splitTopLevel(value)) {
|
|
247
|
+
let color = null;
|
|
248
|
+
let inset = false;
|
|
249
|
+
const lengths = [];
|
|
250
|
+
for (const part of splitTopLevel(layer, true)) {
|
|
251
|
+
if (part === 'inset') { inset = true; continue; }
|
|
252
|
+
const d = dtcgDimension(part);
|
|
253
|
+
if (d) { lengths.push(d); continue; }
|
|
254
|
+
const c = parseColor(part);
|
|
255
|
+
if (!c || color) return null;
|
|
256
|
+
color = c;
|
|
257
|
+
}
|
|
258
|
+
if (!color || lengths.length < 2 || lengths.length > 4) return null;
|
|
259
|
+
const zero = { value: 0, unit: 'px' };
|
|
260
|
+
layers.push({
|
|
261
|
+
color: dtcgColor(color),
|
|
262
|
+
offsetX: lengths[0],
|
|
263
|
+
offsetY: lengths[1],
|
|
264
|
+
blur: lengths[2] || zero,
|
|
265
|
+
spread: lengths[3] || zero,
|
|
266
|
+
...(inset ? { inset: true } : {}),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
if (layers.length === 0) return null;
|
|
270
|
+
return layers.length === 1 ? layers[0] : layers;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Token and group names may not contain { } . or start with $.
|
|
274
|
+
const safeName = (name) => String(name).replace(/[{}.]/g, '-').replace(/^\$/, '_');
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The branding result as a DTCG 2025.10 token file. Values DTCG has no type
|
|
278
|
+
* for (em letter-spacing, multi-value radii, unparsable shadows) are left out
|
|
279
|
+
* and listed under $extensions["dev.crawlforge"].skipped.
|
|
280
|
+
* @param {{ tokens: object, roles?: object, fonts?: Array<{family: string}>, url: string }} input
|
|
281
|
+
*/
|
|
282
|
+
export function toDtcg({ tokens, roles = {}, fonts = [], url }) {
|
|
283
|
+
const skipped = [];
|
|
284
|
+
const color = (hex) => ({ $type: 'color', $value: dtcgColor(parseColor(hex)) });
|
|
285
|
+
const dimension = (group, name, value) => {
|
|
286
|
+
const d = dtcgDimension(value);
|
|
287
|
+
if (d) group[safeName(name)] = { $type: 'dimension', $value: d };
|
|
288
|
+
else skipped.push(name);
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const doc = { $description: `Design tokens read from ${url} by CrawlForge` };
|
|
292
|
+
|
|
293
|
+
const colorGroup = {};
|
|
294
|
+
const roleEntries = Object.entries(roles).filter(([, r]) => r?.value);
|
|
295
|
+
if (roleEntries.length) {
|
|
296
|
+
colorGroup.role = Object.fromEntries(roleEntries.map(([name, r]) => [
|
|
297
|
+
safeName(name),
|
|
298
|
+
{ ...color(r.value), $description: `${r.source}: ${r.evidence}` },
|
|
299
|
+
]));
|
|
300
|
+
}
|
|
301
|
+
for (const [group, steps] of Object.entries(tokens.colors.ramps)) {
|
|
302
|
+
colorGroup[safeName(group)] = Object.fromEntries(Object.entries(steps).map(([s, hex]) => [safeName(s), color(hex)]));
|
|
303
|
+
}
|
|
304
|
+
if (Object.keys(tokens.colors.semantic).length) {
|
|
305
|
+
colorGroup.semantic = Object.fromEntries(Object.entries(tokens.colors.semantic).map(([n, hex]) => [safeName(n), color(hex)]));
|
|
306
|
+
}
|
|
307
|
+
if (Object.keys(colorGroup).length) doc.color = colorGroup;
|
|
308
|
+
|
|
309
|
+
const font = {};
|
|
310
|
+
if (fonts.length) {
|
|
311
|
+
font.family = Object.fromEntries(fonts.slice(0, 6).map(({ family }) => [
|
|
312
|
+
safeName(family.toLowerCase().replace(/\s+/g, '-')),
|
|
313
|
+
{ $type: 'fontFamily', $value: family },
|
|
314
|
+
]));
|
|
315
|
+
}
|
|
316
|
+
for (const [stem, props] of Object.entries(tokens.typography)) {
|
|
317
|
+
const group = {};
|
|
318
|
+
if (props.size) dimension(group, 'size', props.size);
|
|
319
|
+
if (props.lineHeight) {
|
|
320
|
+
if (NUMBER_RE.test(props.lineHeight)) group.lineHeight = { $type: 'number', $value: parseFloat(props.lineHeight) };
|
|
321
|
+
else dimension(group, 'lineHeight', props.lineHeight);
|
|
322
|
+
}
|
|
323
|
+
if (props.weight && NUMBER_RE.test(props.weight)) group.weight = { $type: 'fontWeight', $value: parseFloat(props.weight) };
|
|
324
|
+
if (props.letterSpacing) {
|
|
325
|
+
const d = dtcgDimension(props.letterSpacing);
|
|
326
|
+
if (d) group.letterSpacing = { $type: 'dimension', $value: d };
|
|
327
|
+
else skipped.push(`${stem}-letterSpacing`);
|
|
328
|
+
}
|
|
329
|
+
if (props.family) group.family = { $type: 'fontFamily', $value: splitTopLevel(props.family).map((f) => f.replace(/^['"]|['"]$/g, '')) };
|
|
330
|
+
if (Object.keys(group).length) font[safeName(stem)] = group;
|
|
331
|
+
}
|
|
332
|
+
if (Object.keys(font).length) doc.font = font;
|
|
333
|
+
|
|
334
|
+
for (const kind of ['spacing', 'radius']) {
|
|
335
|
+
const group = {};
|
|
336
|
+
for (const [name, value] of Object.entries(tokens[kind])) dimension(group, name, value);
|
|
337
|
+
if (Object.keys(group).length) doc[kind] = group;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const shadows = {};
|
|
341
|
+
for (const [name, value] of Object.entries(tokens.shadow)) {
|
|
342
|
+
const v = dtcgShadow(value);
|
|
343
|
+
if (v) shadows[safeName(name)] = { $type: 'shadow', $value: v };
|
|
344
|
+
else skipped.push(name);
|
|
345
|
+
}
|
|
346
|
+
if (Object.keys(shadows).length) doc.shadow = shadows;
|
|
347
|
+
|
|
348
|
+
if (skipped.length) doc.$extensions = { 'dev.crawlforge': { skipped: skipped.slice(0, 50) } };
|
|
349
|
+
return doc;
|
|
350
|
+
}
|
|
@@ -148,7 +148,10 @@ export const SCRAPE_INPUT_SHAPE = {
|
|
|
148
148
|
// Optional, additive: only consulted when 'branding' / 'screenshot' is requested.
|
|
149
149
|
brandingOptions: z.object({
|
|
150
150
|
fetchLinkedCss: z.boolean().optional().default(true).describe('Fetch linked stylesheets for richer color/font extraction'),
|
|
151
|
-
maxStylesheets: z.number().min(0).max(20).optional().default(10).describe('Max linked stylesheets to fetch')
|
|
151
|
+
maxStylesheets: z.number().min(0).max(20).optional().default(10).describe('Max linked stylesheets to fetch'),
|
|
152
|
+
computeStyles: z.boolean().optional().default(true).describe('Render the page and read computed styles for colour roles, buttons and type (default: true; false keeps branding to the HTML and CSS)'),
|
|
153
|
+
rawVariables: z.boolean().optional().default(false).describe('Also return every CSS custom property as found, under cssVariables (large on design-system sites)'),
|
|
154
|
+
dtcg: z.boolean().optional().default(false).describe('Also return the tokens as W3C Design Tokens (DTCG 2025.10) JSON, under dtcg')
|
|
152
155
|
}).optional().describe('Options for the "branding" format'),
|
|
153
156
|
screenshotOptions: z.object({
|
|
154
157
|
fullPage: z.boolean().optional().default(false).describe('Capture the full scrollable page'),
|
|
@@ -374,7 +377,7 @@ export class UnifiedScrapeTool {
|
|
|
374
377
|
return this._extractWithLlm;
|
|
375
378
|
}
|
|
376
379
|
|
|
377
|
-
/** Lazy-load an ActionExecutor only when a screenshot is
|
|
380
|
+
/** Lazy-load an ActionExecutor only when a screenshot or computed branding is requested. */
|
|
378
381
|
async _getActionExecutor() {
|
|
379
382
|
if (!this._actionExecutor) {
|
|
380
383
|
const { default: ActionExecutor } = await import('../../core/ActionExecutor.js');
|
|
@@ -383,6 +386,27 @@ export class UnifiedScrapeTool {
|
|
|
383
386
|
return this._actionExecutor;
|
|
384
387
|
}
|
|
385
388
|
|
|
389
|
+
/**
|
|
390
|
+
* One browser render of `url`: the screenshot action when `screenshot` is
|
|
391
|
+
* set, and branding's computed-style probe when `probe` is.
|
|
392
|
+
*/
|
|
393
|
+
async _render(url, { screenshot, probe }) {
|
|
394
|
+
const exec = await this._getActionExecutor();
|
|
395
|
+
const pageProbe = probe ? (await import('./_brandingComputed.js')).brandingProbe : undefined;
|
|
396
|
+
return exec.executeActionChain(
|
|
397
|
+
url,
|
|
398
|
+
{
|
|
399
|
+
actions: screenshot ? [{
|
|
400
|
+
type: 'screenshot',
|
|
401
|
+
fullPage: screenshot.fullPage ?? false,
|
|
402
|
+
format: screenshot.format ?? 'png',
|
|
403
|
+
...(screenshot.quality != null ? { quality: screenshot.quality } : {})
|
|
404
|
+
}] : []
|
|
405
|
+
},
|
|
406
|
+
{ headless: true, timeout: 30000, ...(pageProbe ? { pageProbe } : {}) }
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
386
410
|
/**
|
|
387
411
|
* Execute a unified scrape.
|
|
388
412
|
* @param {object} params - UnifiedScrapeSchema-compatible input
|
|
@@ -667,6 +691,15 @@ export class UnifiedScrapeTool {
|
|
|
667
691
|
}
|
|
668
692
|
}
|
|
669
693
|
|
|
694
|
+
// One browser render serves both formats that need one: the screenshot,
|
|
695
|
+
// and branding's computed styles, which ride along on the same page load.
|
|
696
|
+
const wantsComputed = formats.includes('branding') && brandingOptions?.computeStyles !== false;
|
|
697
|
+
let render = null;
|
|
698
|
+
const renderOnce = () => (render ??= this._render(finalUrl, {
|
|
699
|
+
screenshot: formats.includes('screenshot') ? (screenshotOptions ?? {}) : null,
|
|
700
|
+
probe: wantsComputed
|
|
701
|
+
}));
|
|
702
|
+
|
|
670
703
|
for (const fmt of formats) {
|
|
671
704
|
// JSON format object
|
|
672
705
|
if (fmt && typeof fmt === 'object' && fmt.type === 'json') {
|
|
@@ -879,10 +912,30 @@ export class UnifiedScrapeTool {
|
|
|
879
912
|
|
|
880
913
|
case 'branding':
|
|
881
914
|
try {
|
|
915
|
+
let computed = null;
|
|
916
|
+
if (wantsComputed) {
|
|
917
|
+
const unavailable = (why) => warnings.push(`branding: computed styles unavailable — ${why}; roles are inferred from CSS variable names`);
|
|
918
|
+
if (escalationRan) {
|
|
919
|
+
// A plain render would read the wall the stealth stage got past.
|
|
920
|
+
unavailable('this page needed the stealth browser');
|
|
921
|
+
} else {
|
|
922
|
+
try {
|
|
923
|
+
const r = await renderOnce();
|
|
924
|
+
if (r?.navigationStatus >= 400) unavailable(`the render got HTTP ${r.navigationStatus}`);
|
|
925
|
+
else if (r?.probe) computed = r.probe;
|
|
926
|
+
else unavailable(r?.probeError || r?.error || 'the render returned nothing');
|
|
927
|
+
} catch (err) {
|
|
928
|
+
unavailable(err.message);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
882
932
|
const { extractBranding } = await import('./_brandingExtractor.js');
|
|
883
933
|
const branding = await extractBranding($, finalUrl, {
|
|
884
934
|
fetchLinkedCss: brandingOptions?.fetchLinkedCss ?? true,
|
|
885
|
-
maxStylesheets: brandingOptions?.maxStylesheets ?? 10
|
|
935
|
+
maxStylesheets: brandingOptions?.maxStylesheets ?? 10,
|
|
936
|
+
computed,
|
|
937
|
+
rawVariables: brandingOptions?.rawVariables ?? false,
|
|
938
|
+
dtcg: brandingOptions?.dtcg ?? false
|
|
886
939
|
});
|
|
887
940
|
if (Array.isArray(branding.warnings)) {
|
|
888
941
|
warnings.push(...branding.warnings);
|
|
@@ -898,19 +951,7 @@ export class UnifiedScrapeTool {
|
|
|
898
951
|
case 'screenshot':
|
|
899
952
|
// Opt-in browser path: only launched when 'screenshot' is requested.
|
|
900
953
|
try {
|
|
901
|
-
const
|
|
902
|
-
const r = await exec.executeActionChain(
|
|
903
|
-
finalUrl,
|
|
904
|
-
{
|
|
905
|
-
actions: [{
|
|
906
|
-
type: 'screenshot',
|
|
907
|
-
fullPage: screenshotOptions?.fullPage ?? false,
|
|
908
|
-
format: screenshotOptions?.format ?? 'png',
|
|
909
|
-
...(screenshotOptions?.quality != null ? { quality: screenshotOptions.quality } : {})
|
|
910
|
-
}]
|
|
911
|
-
},
|
|
912
|
-
{ headless: true, timeout: 30000 }
|
|
913
|
-
);
|
|
954
|
+
const r = await renderOnce();
|
|
914
955
|
content.screenshots = Array.isArray(r?.screenshots) ? r.screenshots : [];
|
|
915
956
|
if (r?.success === false) {
|
|
916
957
|
warnings.push(`screenshot: ${r.error || 'capture failed'}`);
|
|
@@ -20,6 +20,55 @@
|
|
|
20
20
|
* poll if cost matters.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
const GOOGLE_HOSTS = new Set(['google.com', 'www.google.com']);
|
|
24
|
+
const BREADCRUMB_HOST = /^https?:\/\/([^\s/›?#]+)/i;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Recover the real destination of an organic result whose link Google wrapped
|
|
28
|
+
* in its own redirect.
|
|
29
|
+
*
|
|
30
|
+
* Since about 2026-10-30 Google serves organic links as
|
|
31
|
+
* `https://google.com/goto?url=<opaque token>`, and DataForSEO passes that
|
|
32
|
+
* through as `url` with `domain: "google.com"`, so no target ever matched. The
|
|
33
|
+
* token is not decodable. The destination host survives in the display
|
|
34
|
+
* `breadcrumb` ("https://www.crawlforge.dev › Docs"); the page URL does not,
|
|
35
|
+
* because breadcrumb segments are labels, not a path.
|
|
36
|
+
*
|
|
37
|
+
* Mirrors `unwrapSerpItem` in the website's `src/lib/tools/serp-unwrap.ts`;
|
|
38
|
+
* keep the two in step.
|
|
39
|
+
*
|
|
40
|
+
* @returns {{ domain: string, url: string|null, wrapped: boolean }}
|
|
41
|
+
*/
|
|
42
|
+
export function unwrapSerpItem(item) {
|
|
43
|
+
const domain = (item.domain || '').toLowerCase();
|
|
44
|
+
const url = item.url || null;
|
|
45
|
+
|
|
46
|
+
let parsed = null;
|
|
47
|
+
try {
|
|
48
|
+
parsed = url ? new URL(url) : null;
|
|
49
|
+
} catch {
|
|
50
|
+
parsed = null;
|
|
51
|
+
}
|
|
52
|
+
const isRedirect =
|
|
53
|
+
parsed !== null &&
|
|
54
|
+
GOOGLE_HOSTS.has(parsed.hostname.toLowerCase()) &&
|
|
55
|
+
(parsed.pathname === '/goto' || parsed.pathname === '/url');
|
|
56
|
+
if (!isRedirect) return { domain, url, wrapped: false };
|
|
57
|
+
|
|
58
|
+
// The older /url?q=<destination> form still carries the real URL.
|
|
59
|
+
const q = parsed.searchParams.get('q');
|
|
60
|
+
if (q && /^https?:\/\//i.test(q)) {
|
|
61
|
+
try {
|
|
62
|
+
return { domain: new URL(q).hostname.toLowerCase(), url: q, wrapped: true };
|
|
63
|
+
} catch {
|
|
64
|
+
// fall through to the breadcrumb
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const match = BREADCRUMB_HOST.exec((item.breadcrumb || '').trim());
|
|
69
|
+
return { domain: match?.[1]?.toLowerCase() ?? '', url: null, wrapped: true };
|
|
70
|
+
}
|
|
71
|
+
|
|
23
72
|
export class DataForSEOSearchAdapter {
|
|
24
73
|
constructor(login, password, options = {}) {
|
|
25
74
|
if (!login || !password) {
|
|
@@ -135,16 +184,27 @@ export class DataForSEOSearchAdapter {
|
|
|
135
184
|
// Keep only true organic results and normalize to a small, stable shape.
|
|
136
185
|
// rank_group = position within the organic results (the "rank" most people mean)
|
|
137
186
|
// rank_absolute = position across ALL SERP elements (ads, snippets, packs, …)
|
|
187
|
+
// Google may wrap result links in its own redirect; unwrapSerpItem recovers
|
|
188
|
+
// the real host so a wrapped result can still match the target.
|
|
189
|
+
let wrappedCount = 0;
|
|
190
|
+
let unresolvedCount = 0;
|
|
138
191
|
const items = rawItems
|
|
139
192
|
.filter((it) => it && it.type === 'organic')
|
|
140
|
-
.map((it) =>
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
193
|
+
.map((it) => {
|
|
194
|
+
const link = unwrapSerpItem(it);
|
|
195
|
+
if (link.wrapped) {
|
|
196
|
+
wrappedCount += 1;
|
|
197
|
+
if (!link.domain) unresolvedCount += 1;
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
position: it.rank_group ?? null,
|
|
201
|
+
rankAbsolute: it.rank_absolute ?? null,
|
|
202
|
+
domain: link.domain,
|
|
203
|
+
url: link.url,
|
|
204
|
+
title: it.title || null,
|
|
205
|
+
snippet: it.description || null,
|
|
206
|
+
};
|
|
207
|
+
});
|
|
148
208
|
|
|
149
209
|
return {
|
|
150
210
|
items,
|
|
@@ -155,6 +215,8 @@ export class DataForSEOSearchAdapter {
|
|
|
155
215
|
device,
|
|
156
216
|
depth,
|
|
157
217
|
organicCount: items.length,
|
|
218
|
+
wrappedCount, // results whose link Google replaced with its own redirect
|
|
219
|
+
unresolvedCount, // wrapped results whose breadcrumb showed no host either
|
|
158
220
|
// Google's "About N results" total. Mobile SERPs (and many desktop
|
|
159
221
|
// ones) show no total, and DataForSEO then reports 0; a 0 beside
|
|
160
222
|
// organic results is unknown, not zero.
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { createHash } from 'crypto';
|
|
2
2
|
|
|
3
|
+
/** A search operator term: `site:example.com`, `-site:x.com`, `filetype:pdf`. */
|
|
4
|
+
const SEARCH_OPERATOR = /^-?[a-z]+:\S+$/i;
|
|
5
|
+
|
|
3
6
|
/**
|
|
4
7
|
* QueryExpander - Advanced query expansion and refinement capabilities
|
|
5
8
|
* Provides synonym expansion, misspelling correction, stemming, phrase detection, and boolean operators
|
|
@@ -33,9 +36,21 @@ export class QueryExpander {
|
|
|
33
36
|
throw new Error('Query must be a non-empty string');
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
// Search operators (site:example.com, -site:, filetype:pdf, inurl:...) are
|
|
40
|
+
// not words: tokenising one drops its restriction, so the search returns
|
|
41
|
+
// pages from anywhere. Expand the free text and carry the operators whole.
|
|
42
|
+
const terms = query.trim().split(/\s+/);
|
|
43
|
+
const operators = terms.filter(term => SEARCH_OPERATOR.test(term));
|
|
44
|
+
if (operators.length > 0) {
|
|
45
|
+
const text = terms.filter(term => !SEARCH_OPERATOR.test(term)).join(' ');
|
|
46
|
+
if (!text) return [query.trim()];
|
|
47
|
+
const [, ...variations] = await this.expandQuery(text, options);
|
|
48
|
+
return [query.trim(), ...variations.map(variation => `${variation} ${operators.join(' ')}`)];
|
|
49
|
+
}
|
|
50
|
+
|
|
36
51
|
const expansionOptions = { ...this.options, ...options };
|
|
37
52
|
const expansions = new Set();
|
|
38
|
-
|
|
53
|
+
|
|
39
54
|
try {
|
|
40
55
|
// Always include the original query
|
|
41
56
|
expansions.add(query.trim());
|
|
@@ -161,8 +161,28 @@ export class SerpRankTool {
|
|
|
161
161
|
snippet: it.snippet,
|
|
162
162
|
}));
|
|
163
163
|
|
|
164
|
-
|
|
164
|
+
// A wrapped result has no URL; its recovered domain still says what it is.
|
|
165
|
+
const relevance = assessSerpRelevance(
|
|
166
|
+
validated.keyword,
|
|
167
|
+
results.map((r) => ({ ...r, url: r.url ?? (r.domain || null) })),
|
|
168
|
+
);
|
|
165
169
|
const suspect = relevance?.suspect ?? false;
|
|
170
|
+
const warnings = [];
|
|
171
|
+
if (suspect) {
|
|
172
|
+
warnings.push(
|
|
173
|
+
`These results look unrelated to the keyword (${relevance.matching} of the top ${relevance.checked} mention it). ` +
|
|
174
|
+
'Google may have served a decoy results page. Re-run the lookup, or check Search Console, before acting on this rank.',
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (meta.wrappedCount > 0) {
|
|
178
|
+
warnings.push(
|
|
179
|
+
`Google hid the destination links of ${meta.wrappedCount} of the ${items.length} results behind its own redirect. ` +
|
|
180
|
+
'Their domains were read from the displayed breadcrumb, so rank matching still works, but their page URLs are unavailable (url is null)' +
|
|
181
|
+
(meta.unresolvedCount > 0
|
|
182
|
+
? `, and ${meta.unresolvedCount} showed no domain at all, so they cannot match any target.`
|
|
183
|
+
: '.'),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
166
186
|
|
|
167
187
|
return {
|
|
168
188
|
configured: true,
|
|
@@ -185,14 +205,7 @@ export class SerpRankTool {
|
|
|
185
205
|
checkedAt: new Date().toISOString(),
|
|
186
206
|
// true when the top results barely mention the keyword: a likely decoy SERP
|
|
187
207
|
suspect,
|
|
188
|
-
...(
|
|
189
|
-
? {
|
|
190
|
-
warnings: [
|
|
191
|
-
`These results look unrelated to the keyword (${relevance.matching} of the top ${relevance.checked} mention it). ` +
|
|
192
|
-
'Google may have served a decoy results page. Re-run the lookup, or check Search Console, before acting on this rank.',
|
|
193
|
-
],
|
|
194
|
-
}
|
|
195
|
-
: {}),
|
|
208
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
196
209
|
};
|
|
197
210
|
}
|
|
198
211
|
}
|