crawlforge-mcp-server 6.23.2 → 6.25.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/package.json +4 -4
- package/server.js +21 -11
- package/src/core/ActionExecutor.js +14 -0
- package/src/core/pageCache.js +249 -0
- package/src/schemas/toolOutputSchemas.js +18 -1
- package/src/server/inlineThreshold.js +12 -5
- package/src/server/requestContext.js +40 -0
- package/src/server/withAuth.js +84 -9
- 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 +5 -1
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +4 -0
- 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/basic/_fetch.js +35 -4
- package/src/tools/basic/extractLinks.js +3 -2
- package/src/tools/basic/extractMetadata.js +3 -2
- package/src/tools/basic/extractText.js +3 -2
- package/src/tools/basic/fetchUrl.js +3 -2
- package/src/tools/basic/scrapeStructured.js +3 -2
- package/src/tools/extract/_fetchAndParse.js +47 -18
- package/src/tools/extract/extractEmbeddedState.js +58 -7
- package/src/tools/extract/extractStructured.js +4 -1
- package/src/tools/extract/extractWithLlm.js +3 -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 +61 -17
- package/src/utils/canonicalUrl.js +29 -0
- package/src/utils/fetchLadder.js +3 -3
- package/src/utils/maxAgeParam.js +18 -0
- package/src/utils/robotsGate.js +24 -5
|
@@ -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
|
+
}
|
|
@@ -33,6 +33,7 @@ import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
|
|
|
33
33
|
import { stripHiddenFromDom } from '../../utils/hiddenContent.js';
|
|
34
34
|
import { extractBlockText } from '../basic/extractText.js';
|
|
35
35
|
import { pageTitle } from '../../utils/pageTitle.js';
|
|
36
|
+
import { MAX_AGE_PARAM } from '../../utils/maxAgeParam.js';
|
|
36
37
|
import { noteHostBlocked, clearHostBlocked, getHostBlock } from '../../utils/hostRateLimiter.js';
|
|
37
38
|
|
|
38
39
|
// ── Schema ────────────────────────────────────────────────────────────────────
|
|
@@ -147,7 +148,10 @@ export const SCRAPE_INPUT_SHAPE = {
|
|
|
147
148
|
// Optional, additive: only consulted when 'branding' / 'screenshot' is requested.
|
|
148
149
|
brandingOptions: z.object({
|
|
149
150
|
fetchLinkedCss: z.boolean().optional().default(true).describe('Fetch linked stylesheets for richer color/font extraction'),
|
|
150
|
-
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')
|
|
151
155
|
}).optional().describe('Options for the "branding" format'),
|
|
152
156
|
screenshotOptions: z.object({
|
|
153
157
|
fullPage: z.boolean().optional().default(false).describe('Capture the full scrollable page'),
|
|
@@ -168,7 +172,8 @@ export const UnifiedScrapeSchema = z.object({
|
|
|
168
172
|
// Compliance overrides, per request: identify as yourself for a target you
|
|
169
173
|
// have your own agreement with, and take responsibility for ignoring robots.
|
|
170
174
|
user_agent: z.string().optional(),
|
|
171
|
-
respect_robots: z.boolean().optional()
|
|
175
|
+
respect_robots: z.boolean().optional(),
|
|
176
|
+
...MAX_AGE_PARAM
|
|
172
177
|
});
|
|
173
178
|
|
|
174
179
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
@@ -372,7 +377,7 @@ export class UnifiedScrapeTool {
|
|
|
372
377
|
return this._extractWithLlm;
|
|
373
378
|
}
|
|
374
379
|
|
|
375
|
-
/** Lazy-load an ActionExecutor only when a screenshot is
|
|
380
|
+
/** Lazy-load an ActionExecutor only when a screenshot or computed branding is requested. */
|
|
376
381
|
async _getActionExecutor() {
|
|
377
382
|
if (!this._actionExecutor) {
|
|
378
383
|
const { default: ActionExecutor } = await import('../../core/ActionExecutor.js');
|
|
@@ -381,6 +386,27 @@ export class UnifiedScrapeTool {
|
|
|
381
386
|
return this._actionExecutor;
|
|
382
387
|
}
|
|
383
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
|
+
|
|
384
410
|
/**
|
|
385
411
|
* Execute a unified scrape.
|
|
386
412
|
* @param {object} params - UnifiedScrapeSchema-compatible input
|
|
@@ -432,6 +458,7 @@ export class UnifiedScrapeTool {
|
|
|
432
458
|
timeoutMs,
|
|
433
459
|
userAgent: validated.user_agent,
|
|
434
460
|
respectRobots: validated.respect_robots,
|
|
461
|
+
maxAge: validated.maxAge,
|
|
435
462
|
tool: 'scrape',
|
|
436
463
|
stripTags: [], // we handle boilerplate ourselves
|
|
437
464
|
// A real wall reaches a plain fetch as a 403 with the challenge in
|
|
@@ -664,6 +691,15 @@ export class UnifiedScrapeTool {
|
|
|
664
691
|
}
|
|
665
692
|
}
|
|
666
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
|
+
|
|
667
703
|
for (const fmt of formats) {
|
|
668
704
|
// JSON format object
|
|
669
705
|
if (fmt && typeof fmt === 'object' && fmt.type === 'json') {
|
|
@@ -876,10 +912,30 @@ export class UnifiedScrapeTool {
|
|
|
876
912
|
|
|
877
913
|
case 'branding':
|
|
878
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
|
+
}
|
|
879
932
|
const { extractBranding } = await import('./_brandingExtractor.js');
|
|
880
933
|
const branding = await extractBranding($, finalUrl, {
|
|
881
934
|
fetchLinkedCss: brandingOptions?.fetchLinkedCss ?? true,
|
|
882
|
-
maxStylesheets: brandingOptions?.maxStylesheets ?? 10
|
|
935
|
+
maxStylesheets: brandingOptions?.maxStylesheets ?? 10,
|
|
936
|
+
computed,
|
|
937
|
+
rawVariables: brandingOptions?.rawVariables ?? false,
|
|
938
|
+
dtcg: brandingOptions?.dtcg ?? false
|
|
883
939
|
});
|
|
884
940
|
if (Array.isArray(branding.warnings)) {
|
|
885
941
|
warnings.push(...branding.warnings);
|
|
@@ -895,19 +951,7 @@ export class UnifiedScrapeTool {
|
|
|
895
951
|
case 'screenshot':
|
|
896
952
|
// Opt-in browser path: only launched when 'screenshot' is requested.
|
|
897
953
|
try {
|
|
898
|
-
const
|
|
899
|
-
const r = await exec.executeActionChain(
|
|
900
|
-
finalUrl,
|
|
901
|
-
{
|
|
902
|
-
actions: [{
|
|
903
|
-
type: 'screenshot',
|
|
904
|
-
fullPage: screenshotOptions?.fullPage ?? false,
|
|
905
|
-
format: screenshotOptions?.format ?? 'png',
|
|
906
|
-
...(screenshotOptions?.quality != null ? { quality: screenshotOptions.quality } : {})
|
|
907
|
-
}]
|
|
908
|
-
},
|
|
909
|
-
{ headless: true, timeout: 30000 }
|
|
910
|
-
);
|
|
954
|
+
const r = await renderOnce();
|
|
911
955
|
content.screenshots = Array.isArray(r?.screenshots) ? r.screenshots : [];
|
|
912
956
|
if (r?.success === false) {
|
|
913
957
|
warnings.push(`screenshot: ${r.error || 'capture failed'}`);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* canonicalUrl — one spelling per page, for keys that must match when two
|
|
3
|
+
* calls ask for the same page (the invocation log's urlKey now, the page cache
|
|
4
|
+
* of REPEAT_CALL_REDUCTION_PLAN Phase 1 next).
|
|
5
|
+
*
|
|
6
|
+
* Rules (plan 1.2): lowercase scheme and host, drop the default port (80 for
|
|
7
|
+
* http, 443 for https) and the #fragment, keep path and query string exactly
|
|
8
|
+
* as written. An empty path is the root path ("/"), which is what is requested.
|
|
9
|
+
* Anything that does not parse as a URL returns null.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export function canonicalUrl(input) {
|
|
13
|
+
if (typeof input !== 'string') return null;
|
|
14
|
+
let u;
|
|
15
|
+
try { u = new URL(input.trim()); } catch { return null; }
|
|
16
|
+
|
|
17
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
18
|
+
u.hash = '';
|
|
19
|
+
return u.href;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// WHATWG URL already lowercases scheme and host and drops a default port,
|
|
23
|
+
// but it also rewrites the path (dot segments, percent-encoding), so the
|
|
24
|
+
// path and query are taken from the original string instead.
|
|
25
|
+
const rest = input.trim().match(/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*([^#]*)/i)?.[1] ?? '';
|
|
26
|
+
const userinfo = u.username ? `${u.username}${u.password ? `:${u.password}` : ''}@` : '';
|
|
27
|
+
const pathAndQuery = rest === '' || rest.startsWith('?') ? `/${rest}` : rest;
|
|
28
|
+
return `${u.protocol}//${userinfo}${u.host}${pathAndQuery}`;
|
|
29
|
+
}
|
package/src/utils/fetchLadder.js
CHANGED
|
@@ -122,7 +122,7 @@ async function plainFetch(url, options) {
|
|
|
122
122
|
|
|
123
123
|
/**
|
|
124
124
|
* @param {string} url
|
|
125
|
-
* @param {{ tool: 'extract_text'|'extract_links', userAgent?: string, respectRobots?: boolean,
|
|
125
|
+
* @param {{ tool: 'extract_text'|'extract_links', userAgent?: string, respectRobots?: boolean, maxAge?: number,
|
|
126
126
|
* escalate?: boolean, escalateEngine?: string,
|
|
127
127
|
* escalateFetch?: (args: { url: string, engine: string, respectRobots?: boolean }) => Promise<object> }} options
|
|
128
128
|
* @returns {Promise<
|
|
@@ -132,10 +132,10 @@ async function plainFetch(url, options) {
|
|
|
132
132
|
* `fields` carries `escalated` (only when escalate was asked for), `stealth`
|
|
133
133
|
* (when the stage ran) and `rendered: false` (a client-rendered shell)
|
|
134
134
|
*/
|
|
135
|
-
export async function fetchLadder(url, { tool, userAgent, respectRobots, escalate = false, escalateEngine = 'auto', escalateFetch } = {}) {
|
|
135
|
+
export async function fetchLadder(url, { tool, userAgent, respectRobots, maxAge, escalate = false, escalateEngine = 'auto', escalateFetch } = {}) {
|
|
136
136
|
let escalationRan = false;
|
|
137
137
|
try {
|
|
138
|
-
const response = await plainFetch(url, { timeout: LADDER_TIMEOUT_MS, userAgent, respectRobots, tool });
|
|
138
|
+
const response = await plainFetch(url, { timeout: LADDER_TIMEOUT_MS, userAgent, respectRobots, maxAge, tool });
|
|
139
139
|
const type = mediaType(response);
|
|
140
140
|
if (response.ok && type && !TEXTUAL_TYPE.test(type)) return { unsupported: type };
|
|
141
141
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* maxAgeParam — the one declaration of `maxAge` (repeat-call reduction plan,
|
|
3
|
+
* Phase 1, 1.5 / D2). Passed through as `options.maxAge` to the two page-cache
|
|
4
|
+
* seams, fetchWithTimeout and fetchAndParse; undefined means the server's
|
|
5
|
+
* default TTL there.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { pageCacheActive } from '../core/pageCache.js';
|
|
10
|
+
|
|
11
|
+
export const MAX_AGE_PARAM = {
|
|
12
|
+
maxAge: z.number().int().min(0).optional().describe("Reuse this session's copy of the page if it was fetched within this many ms (default 600000 = 10 min); 0 always fetches fresh.")
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Phase 3 (3.1): the one sentence the nine cached tools' descriptions carry.
|
|
16
|
+
// Empty where the cache is off (hosted HTTP, CRAWLFORGE_PAGE_CACHE_MB=0).
|
|
17
|
+
export const PAGE_CACHE_TEXT = ' Asking again for the same page with different formats, paths or selectors reuses this session\'s copy for 10 minutes at 1 credit; pass maxAge: 0 to force a fresh fetch.';
|
|
18
|
+
export const PAGE_CACHE_SENTENCE = pageCacheActive() ? PAGE_CACHE_TEXT : '';
|
package/src/utils/robotsGate.js
CHANGED
|
@@ -273,6 +273,29 @@ export function outboundHeaders(userAgent, signature = {}) {
|
|
|
273
273
|
};
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
/**
|
|
277
|
+
* The gate's decision, refusing as {@link preflightFetch} does, without the
|
|
278
|
+
* per-host throttle. A page-cache hit (src/core/pageCache.js) sends no
|
|
279
|
+
* request, so there is nothing to space, but the blocklist and robots.txt are
|
|
280
|
+
* asked again: either may have changed since the copy was fetched.
|
|
281
|
+
*
|
|
282
|
+
* @param {string} url
|
|
283
|
+
* @param {object} [options] see {@link robotsPreflight}
|
|
284
|
+
* @param {{ redirectedFrom?: string }} [reached] set when `url` is where a
|
|
285
|
+
* redirect from the requested URL led
|
|
286
|
+
* @returns {Promise<{ allowed: true, userAgent: string, crawlDelayMs: number,
|
|
287
|
+
* warnings: string[], overridden: boolean }>}
|
|
288
|
+
* @throws {BlockedHostError|RobotsDisallowedError}
|
|
289
|
+
*/
|
|
290
|
+
export async function preflightDecision(url, options = {}, reached = {}) {
|
|
291
|
+
const decision = await robotsPreflight(url, options);
|
|
292
|
+
if (!decision.allowed) {
|
|
293
|
+
markPreflightRefusal('ROBOTS_DISALLOWED');
|
|
294
|
+
throw new RobotsDisallowedError(url, reached);
|
|
295
|
+
}
|
|
296
|
+
return decision;
|
|
297
|
+
}
|
|
298
|
+
|
|
276
299
|
/**
|
|
277
300
|
* The call-site helper: run the gate, honour Crawl-delay and any recorded
|
|
278
301
|
* `Retry-After`, and hand back the identity headers to send and the gate for
|
|
@@ -286,11 +309,7 @@ export function outboundHeaders(userAgent, signature = {}) {
|
|
|
286
309
|
* @throws {BlockedHostError|RobotsDisallowedError}
|
|
287
310
|
*/
|
|
288
311
|
export async function preflightFetch(url, options = {}) {
|
|
289
|
-
const decision = await
|
|
290
|
-
if (!decision.allowed) {
|
|
291
|
-
markPreflightRefusal('ROBOTS_DISALLOWED');
|
|
292
|
-
throw new RobotsDisallowedError(url);
|
|
293
|
-
}
|
|
312
|
+
const decision = await preflightDecision(url, options);
|
|
294
313
|
|
|
295
314
|
await throttleHost(url, { crawlDelayMs: decision.crawlDelayMs });
|
|
296
315
|
|