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
|
@@ -16,6 +16,7 @@ import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
|
16
16
|
import { config } from '../../constants/config.js';
|
|
17
17
|
import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
|
|
18
18
|
import { preflightFetch } from '../../utils/robotsGate.js';
|
|
19
|
+
import { pageFromResponse, pageHeader, throughPageCache } from '../../core/pageCache.js';
|
|
19
20
|
|
|
20
21
|
const DEFAULT_TIMEOUT_MS = 15000;
|
|
21
22
|
|
|
@@ -108,16 +109,60 @@ export { flattenText as flattenBodyText };
|
|
|
108
109
|
* @param {number} [options.timeoutMs]
|
|
109
110
|
* @param {string[]} [options.stripTags] — additional tags to strip (default: script, style, noscript, iframe, svg)
|
|
110
111
|
* @param {boolean} [options.errorDocuments] — return a non-2xx HTML/text body with its status instead of throwing
|
|
112
|
+
* @param {number} [options.maxAge] — page cache: reuse a copy at most this many ms old (default 10 min; 0 = fetch fresh)
|
|
113
|
+
* @param {boolean} [options.noCache] — page cache: neither read nor store
|
|
111
114
|
* @returns {Promise<{ html: string, $: import('cheerio').CheerioAPI, textContent: string, finalUrl: string, warnings: string[], status: number }>}
|
|
112
115
|
*/
|
|
113
116
|
export async function fetchAndParse(url, options = {}) {
|
|
117
|
+
const { stripTags = ['script', 'style', 'noscript', 'iframe', 'svg'] } = options;
|
|
118
|
+
|
|
119
|
+
// The page itself may come from the process page cache (src/core/pageCache.js);
|
|
120
|
+
// the parse below is always this call's own, so no two calls share a `$`.
|
|
121
|
+
const page = await throughPageCache(
|
|
122
|
+
url,
|
|
123
|
+
options,
|
|
124
|
+
'html',
|
|
125
|
+
async () => {
|
|
126
|
+
const fetched = await fetchPage(url, options);
|
|
127
|
+
return { result: fetched, page: fetched };
|
|
128
|
+
},
|
|
129
|
+
(entry, warnings) => ({ ...entry, warnings })
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const html = page.body;
|
|
133
|
+
const { warnings, status } = page;
|
|
134
|
+
const finalUrl = page.url;
|
|
135
|
+
|
|
136
|
+
// text/plain and application/json aren't markup: running them through the
|
|
137
|
+
// HTML parser risks misinterpreting substrings (e.g. a "<script>" value
|
|
138
|
+
// inside a JSON string) as real tags and stripping/mangling content.
|
|
139
|
+
if (classifyContentType(pageHeader(page, 'content-type')) === 'text') {
|
|
140
|
+
return { html, $: load(''), textContent: html.trim(), finalUrl, warnings, status };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const $ = load(html);
|
|
144
|
+
|
|
145
|
+
if (stripTags.length > 0) {
|
|
146
|
+
$(stripTags.join(', ')).remove();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const textContent = flattenText($);
|
|
150
|
+
|
|
151
|
+
return { html, $, textContent, finalUrl, warnings, status };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The network fetch behind fetchAndParse: the gate, the request, the status
|
|
156
|
+
* and content-type checks and the capped body read.
|
|
157
|
+
* @returns {Promise<object>} the page as pageFromResponse gives it, plus `warnings`
|
|
158
|
+
*/
|
|
159
|
+
async function fetchPage(url, options) {
|
|
114
160
|
const {
|
|
115
161
|
userAgent,
|
|
116
162
|
respectRobots,
|
|
117
163
|
tool,
|
|
118
164
|
apiKey,
|
|
119
165
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
120
|
-
stripTags = ['script', 'style', 'noscript', 'iframe', 'svg'],
|
|
121
166
|
errorDocuments = false
|
|
122
167
|
} = options;
|
|
123
168
|
|
|
@@ -157,21 +202,5 @@ export async function fetchAndParse(url, options = {}) {
|
|
|
157
202
|
}
|
|
158
203
|
|
|
159
204
|
const html = await readTextWithSizeCap(response);
|
|
160
|
-
|
|
161
|
-
// text/plain and application/json aren't markup: running them through the
|
|
162
|
-
// HTML parser risks misinterpreting substrings (e.g. a "<script>" value
|
|
163
|
-
// inside a JSON string) as real tags and stripping/mangling content.
|
|
164
|
-
if (classification === 'text') {
|
|
165
|
-
return { html, $: load(''), textContent: html.trim(), finalUrl: response.url, warnings, status: response.status };
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const $ = load(html);
|
|
169
|
-
|
|
170
|
-
if (stripTags.length > 0) {
|
|
171
|
-
$(stripTags.join(', ')).remove();
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const textContent = flattenText($);
|
|
175
|
-
|
|
176
|
-
return { html, $, textContent, finalUrl: response.url, warnings, status: response.status };
|
|
205
|
+
return { ...pageFromResponse(response, html), warnings };
|
|
177
206
|
}
|
|
@@ -21,7 +21,7 @@ import { z } from 'zod';
|
|
|
21
21
|
import { fetchAndParse } from './_fetchAndParse.js';
|
|
22
22
|
// Both live in crawlforge-extractors so the REST API's extract_embedded_state
|
|
23
23
|
// runs this exact reader — one RSC flight-stream parser, not two.
|
|
24
|
-
import { extractEmbeddedState, selectJsonPath, findJsonPaths } from 'crawlforge-extractors';
|
|
24
|
+
import { extractEmbeddedState, selectJsonPath, findJsonPaths, parseJsonPath } from 'crawlforge-extractors';
|
|
25
25
|
import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
|
|
26
26
|
import { pageTitle } from '../../utils/pageTitle.js';
|
|
27
27
|
import { setActualCost, markPreflightRefusal } from '../../server/requestContext.js';
|
|
@@ -38,6 +38,12 @@ const EMBEDDED_STATE_BASE_CREDITS = 2;
|
|
|
38
38
|
// `find` ask for less up front.
|
|
39
39
|
const LARGE_RESULT_BYTES = 256_000;
|
|
40
40
|
|
|
41
|
+
// A path miss carries the key tree where the path stopped, so the next path is
|
|
42
|
+
// aimed rather than guessed (repeat-call plan 2.1). Capped per object and in
|
|
43
|
+
// characters; over the character cap it drops to one level.
|
|
44
|
+
const MISS_TREE_KEYS = 30;
|
|
45
|
+
const MISS_TREE_CHARS = 2000;
|
|
46
|
+
|
|
41
47
|
// A wall, as opposed to a missing page or a broken server (G4: a 404 or a 5xx
|
|
42
48
|
// never escalates — see aBrowserMightPass).
|
|
43
49
|
const WALL_STATUSES = new Set([403, 429, 444]);
|
|
@@ -75,24 +81,49 @@ function label(value) {
|
|
|
75
81
|
* Shared shape with the REST route — keep the two in step.
|
|
76
82
|
* @param {unknown} value
|
|
77
83
|
* @param {number} depth
|
|
84
|
+
* @param {number} [maxKeys] per object; the rest are counted under "…"
|
|
78
85
|
*/
|
|
79
|
-
export function describeKeys(value, depth) {
|
|
86
|
+
export function describeKeys(value, depth, maxKeys = Infinity) {
|
|
80
87
|
if (Array.isArray(value)) {
|
|
81
88
|
if (depth <= 1) return label(value);
|
|
82
89
|
return value.length > 0
|
|
83
|
-
? { length: value.length, first: describeKeys(value[0], depth - 1) }
|
|
90
|
+
? { length: value.length, first: describeKeys(value[0], depth - 1, maxKeys) }
|
|
84
91
|
: { length: 0 };
|
|
85
92
|
}
|
|
86
93
|
if (value !== null && typeof value === 'object') {
|
|
87
94
|
const keys = {};
|
|
88
|
-
|
|
89
|
-
|
|
95
|
+
const entries = Object.entries(value);
|
|
96
|
+
for (const [key, child] of entries.slice(0, maxKeys)) {
|
|
97
|
+
keys[key] = depth > 1 ? describeKeys(child, depth - 1, maxKeys) : label(child);
|
|
90
98
|
}
|
|
99
|
+
if (entries.length > maxKeys) keys['…'] = `${entries.length - maxKeys} more keys`;
|
|
91
100
|
return keys;
|
|
92
101
|
}
|
|
93
102
|
return label(value);
|
|
94
103
|
}
|
|
95
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The key tree where `path` stops resolving: `null` when the value there has
|
|
107
|
+
* no keys (the path ran into a string or number).
|
|
108
|
+
* @param {unknown} root
|
|
109
|
+
* @param {string} path
|
|
110
|
+
* @returns {{ at: string, tree: string } | null}
|
|
111
|
+
*/
|
|
112
|
+
function missKeyTree(root, path) {
|
|
113
|
+
let current = root;
|
|
114
|
+
const walked = [];
|
|
115
|
+
for (const segment of parseJsonPath(path)) {
|
|
116
|
+
const key = Array.isArray(current) ? Number(segment) : segment;
|
|
117
|
+
if (current === null || typeof current !== 'object' || !Object.hasOwn(current, key)) break;
|
|
118
|
+
current = current[key];
|
|
119
|
+
walked.push(segment);
|
|
120
|
+
}
|
|
121
|
+
if (current === null || typeof current !== 'object') return null;
|
|
122
|
+
let tree = JSON.stringify(describeKeys(current, 2, MISS_TREE_KEYS));
|
|
123
|
+
if (tree.length > MISS_TREE_CHARS) tree = JSON.stringify(describeKeys(current, 1, MISS_TREE_KEYS));
|
|
124
|
+
return { at: walked.join('.'), tree };
|
|
125
|
+
}
|
|
126
|
+
|
|
96
127
|
function httpError(status) {
|
|
97
128
|
return new Error(`HTTP ${status}: ${STATUS_CODES[status] ?? ''}`.trimEnd());
|
|
98
129
|
}
|
|
@@ -120,7 +151,7 @@ function failed(verdict, status, gotState) {
|
|
|
120
151
|
*/
|
|
121
152
|
export function createExtractEmbeddedStateHandler({ escalateFetch } = {}) {
|
|
122
153
|
return async function extractEmbeddedStateHandler({
|
|
123
|
-
url, path, keys_only, find, raw = false, escalate, escalate_engine = 'auto', wait_for, user_agent, respect_robots
|
|
154
|
+
url, path, keys_only, find, raw = false, escalate, escalate_engine = 'auto', wait_for, user_agent, respect_robots, maxAge
|
|
124
155
|
}) {
|
|
125
156
|
// Each replaces `data` with something else, so asking for both has no
|
|
126
157
|
// answer. Nothing was fetched, so - like a robots refusal - it costs nothing.
|
|
@@ -149,6 +180,7 @@ export function createExtractEmbeddedStateHandler({ escalateFetch } = {}) {
|
|
|
149
180
|
const fetched = await fetchAndParse(url, {
|
|
150
181
|
userAgent: user_agent,
|
|
151
182
|
respectRobots: respect_robots,
|
|
183
|
+
maxAge,
|
|
152
184
|
tool: 'extract_embedded_state',
|
|
153
185
|
errorDocuments: true
|
|
154
186
|
});
|
|
@@ -283,7 +315,26 @@ export function createExtractEmbeddedStateHandler({ escalateFetch } = {}) {
|
|
|
283
315
|
}
|
|
284
316
|
}
|
|
285
317
|
|
|
286
|
-
|
|
318
|
+
let data = state.data;
|
|
319
|
+
if (effectivePath) {
|
|
320
|
+
try {
|
|
321
|
+
data = selectJsonPath(state.data, effectivePath);
|
|
322
|
+
} catch (error) {
|
|
323
|
+
// A path miss: the key tree where it stopped, and a next step that
|
|
324
|
+
// stays on this tool (the fallback hint would say to drop `path`).
|
|
325
|
+
reportCost();
|
|
326
|
+
const miss = missKeyTree(state.data, effectivePath);
|
|
327
|
+
return {
|
|
328
|
+
content: [{
|
|
329
|
+
type: 'text',
|
|
330
|
+
text: `Failed to extract embedded state: ${error.message}` +
|
|
331
|
+
(miss ? `\nKeys at ${miss.at ? `"${miss.at}"` : 'the top level'}: ${miss.tree}` : '') +
|
|
332
|
+
'\nNext step: Call again with a path built from the keys above, or with find:"<key>" to get the paths where a field of that name lives.'
|
|
333
|
+
}],
|
|
334
|
+
isError: true
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
287
338
|
const bytes = Buffer.byteLength(JSON.stringify(data) ?? '');
|
|
288
339
|
|
|
289
340
|
if (!path && !keys_only && find === undefined && bytes > LARGE_RESULT_BYTES) {
|
|
@@ -9,6 +9,7 @@ import { ElicitationHelper } from '../../core/ElicitationHelper.js'; // D1.4
|
|
|
9
9
|
import { load } from 'cheerio';
|
|
10
10
|
import { LLMManager } from '../../core/llm/LLMManager.js';
|
|
11
11
|
import { CRAWLFORGE_USER_AGENT } from '../../utils/fetchIdentity.js';
|
|
12
|
+
import { MAX_AGE_PARAM } from '../../utils/maxAgeParam.js';
|
|
12
13
|
import { fetchAndParse, flattenBodyText } from './_fetchAndParse.js';
|
|
13
14
|
import { fetchErrorMessage } from '../../utils/fetchError.js';
|
|
14
15
|
import { extractMainContent } from '../scrape/_mainContent.js';
|
|
@@ -267,6 +268,7 @@ const ExtractStructuredSchema = z.object({
|
|
|
267
268
|
selectorHints: z.record(z.string()).optional(),
|
|
268
269
|
respect_robots: z.boolean().optional(),
|
|
269
270
|
user_agent: z.string().optional(),
|
|
271
|
+
...MAX_AGE_PARAM,
|
|
270
272
|
verify_numbers: z.boolean().optional().default(true)
|
|
271
273
|
});
|
|
272
274
|
|
|
@@ -324,7 +326,7 @@ export class ExtractStructuredTool {
|
|
|
324
326
|
|
|
325
327
|
try {
|
|
326
328
|
const validated = ExtractStructuredSchema.parse(params);
|
|
327
|
-
const { url, schema: requestedSchema, prompt, llmConfig, fallbackToSelectors, selectorHints, respect_robots, user_agent, verify_numbers } = validated;
|
|
329
|
+
const { url, schema: requestedSchema, prompt, llmConfig, fallbackToSelectors, selectorHints, respect_robots, user_agent, maxAge, verify_numbers } = validated;
|
|
328
330
|
// `type` is optional on input, and the tool's own example omits it. The
|
|
329
331
|
// validator reads a typeless schema as "anything", so the CSS fallback
|
|
330
332
|
// reported valid:true for "2 reviews" in an integer field (R24). A schema
|
|
@@ -387,6 +389,7 @@ export class ExtractStructuredTool {
|
|
|
387
389
|
const { html, $, textContent, warnings } = await fetchAndParse(url, {
|
|
388
390
|
userAgent: user_agent || this.userAgent,
|
|
389
391
|
respectRobots: respect_robots,
|
|
392
|
+
maxAge,
|
|
390
393
|
tool: 'extract_structured'
|
|
391
394
|
});
|
|
392
395
|
|
|
@@ -440,6 +440,7 @@ export class ExtractWithLlm {
|
|
|
440
440
|
* @param {number} [params.maxTokens] - Max output tokens (default 4096)
|
|
441
441
|
* @param {boolean} [params.respect_robots] - Per-request robots.txt override
|
|
442
442
|
* @param {string} [params.user_agent] - Per-request identity override
|
|
443
|
+
* @param {number} [params.maxAge] - Page-cache age limit in ms, passed to fetchAndParse
|
|
443
444
|
* @param {boolean} [params.verify_numbers] - Numeric provenance guard (default true)
|
|
444
445
|
* @param {AbortSignal} [params.signal] - Aborts the model request(s); an aborted
|
|
445
446
|
* call skips the retry and the sampling fallback
|
|
@@ -456,6 +457,7 @@ export class ExtractWithLlm {
|
|
|
456
457
|
maxTokens = 4096,
|
|
457
458
|
respect_robots,
|
|
458
459
|
user_agent,
|
|
460
|
+
maxAge,
|
|
459
461
|
verify_numbers = true,
|
|
460
462
|
signal
|
|
461
463
|
} = params;
|
|
@@ -501,6 +503,7 @@ export class ExtractWithLlm {
|
|
|
501
503
|
const { html, textContent, warnings } = await fetchAndParse(url, {
|
|
502
504
|
respectRobots: respect_robots,
|
|
503
505
|
userAgent: user_agent,
|
|
506
|
+
maxAge,
|
|
504
507
|
tool: 'extract_with_llm'
|
|
505
508
|
});
|
|
506
509
|
text = textContent;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* _brandingColor.js — CSS colour parsing for the branding format.
|
|
3
|
+
*
|
|
4
|
+
* parseColor turns hex, rgb(), hsl(), oklch(), oklab(), lab(), lch() and the
|
|
5
|
+
* basic named colours into { r, g, b, a } (channels 0-255, alpha 0-1). The
|
|
6
|
+
* wide-gamut forms matter because Tailwind v4 writes its palette in oklch and
|
|
7
|
+
* its build (Lightning CSS) ships it as lab(). The helpers below it
|
|
8
|
+
* compare colours: how saturated one is, how light, how far apart two are.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const NAMED_COLORS = {
|
|
12
|
+
black: '#000000', white: '#ffffff', red: '#ff0000', green: '#008000',
|
|
13
|
+
blue: '#0000ff', yellow: '#ffff00', orange: '#ffa500', purple: '#800080',
|
|
14
|
+
gray: '#808080', grey: '#808080', silver: '#c0c0c0', navy: '#000080',
|
|
15
|
+
teal: '#008080', maroon: '#800000', olive: '#808000', lime: '#00ff00',
|
|
16
|
+
aqua: '#00ffff', fuchsia: '#ff00ff',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function clamp(n, lo, hi) {
|
|
20
|
+
return Math.max(lo, Math.min(hi, n));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function toHex2(n) {
|
|
24
|
+
return clamp(Math.round(n), 0, 255).toString(16).padStart(2, '0');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A channel or alpha argument: `none` is 0, a percentage scales to `full`.
|
|
28
|
+
function num(part, full = 1) {
|
|
29
|
+
if (part === 'none') return 0;
|
|
30
|
+
if (part.endsWith('%')) return (parseFloat(part) / 100) * full;
|
|
31
|
+
return parseFloat(part);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function alphaOf(part) {
|
|
35
|
+
if (part == null) return 1;
|
|
36
|
+
const a = num(part);
|
|
37
|
+
return Number.isNaN(a) ? 1 : clamp(a, 0, 1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hslToRgb(h, s, l) {
|
|
41
|
+
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
42
|
+
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
43
|
+
const mm = l - c / 2;
|
|
44
|
+
let r = 0, g = 0, b = 0;
|
|
45
|
+
const hh = ((h % 360) + 360) % 360;
|
|
46
|
+
if (hh < 60) [r, g, b] = [c, x, 0];
|
|
47
|
+
else if (hh < 120) [r, g, b] = [x, c, 0];
|
|
48
|
+
else if (hh < 180) [r, g, b] = [0, c, x];
|
|
49
|
+
else if (hh < 240) [r, g, b] = [0, x, c];
|
|
50
|
+
else if (hh < 300) [r, g, b] = [x, 0, c];
|
|
51
|
+
else [r, g, b] = [c, 0, x];
|
|
52
|
+
return [(r + mm) * 255, (g + mm) * 255, (b + mm) * 255];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Linear-light channels -> gamma-encoded sRGB 0-255, clipped to the gamut.
|
|
56
|
+
function encode(lin) {
|
|
57
|
+
return lin.map((x) => {
|
|
58
|
+
const v = clamp(x, 0, 1);
|
|
59
|
+
return (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055) * 255;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// CIE Lab (D50) -> XYZ D50 -> Bradford to D65 -> linear sRGB (CSS Color 4 matrices).
|
|
64
|
+
function labToRgb(L, a, b) {
|
|
65
|
+
const e = 216 / 24389;
|
|
66
|
+
const k = 24389 / 27;
|
|
67
|
+
const fy = (L + 16) / 116;
|
|
68
|
+
const fx = fy + a / 500;
|
|
69
|
+
const fz = fy - b / 200;
|
|
70
|
+
const x = (fx ** 3 > e ? fx ** 3 : (116 * fx - 16) / k) * 0.3457 / 0.3585;
|
|
71
|
+
const y = L > k * e ? fy ** 3 : L / k;
|
|
72
|
+
const z = (fz ** 3 > e ? fz ** 3 : (116 * fz - 16) / k) * (1 - 0.3457 - 0.3585) / 0.3585;
|
|
73
|
+
const X = 0.9554734527042182 * x - 0.023098536874261423 * y + 0.0632593086610217 * z;
|
|
74
|
+
const Y = -0.028369706963208136 * x + 1.0099954580058226 * y + 0.021041398966943008 * z;
|
|
75
|
+
const Z = 0.012314001688319899 * x - 0.020507696433477912 * y + 1.3303659366080753 * z;
|
|
76
|
+
return encode([
|
|
77
|
+
3.2409699419045226 * X - 1.537383177570094 * Y - 0.4986107602930034 * Z,
|
|
78
|
+
-0.9692436362808796 * X + 1.8759675015077202 * Y + 0.04155505740717559 * Z,
|
|
79
|
+
0.05563007969699366 * X - 0.20397695888897652 * Y + 1.0569715142428786 * Z,
|
|
80
|
+
]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// OKLab -> sRGB (Björn Ottosson's reference matrices), clipped to the gamut.
|
|
84
|
+
function oklabToRgb(L, a, b) {
|
|
85
|
+
const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
|
86
|
+
const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
|
87
|
+
const s = (L - 0.0894841775 * a - 1.2914855480 * b) ** 3;
|
|
88
|
+
return encode([
|
|
89
|
+
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
90
|
+
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
91
|
+
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
|
|
92
|
+
]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse a CSS colour into { r, g, b, a }. Returns null for anything that is
|
|
97
|
+
* not a literal colour (gradients, currentColor, var(), transparent).
|
|
98
|
+
*/
|
|
99
|
+
export function parseColor(raw) {
|
|
100
|
+
if (!raw) return null;
|
|
101
|
+
const v = String(raw).trim().toLowerCase();
|
|
102
|
+
|
|
103
|
+
const named = NAMED_COLORS[v];
|
|
104
|
+
if (named) return parseColor(named);
|
|
105
|
+
|
|
106
|
+
let m = v.match(/^#([0-9a-f]{3,8})$/);
|
|
107
|
+
if (m) {
|
|
108
|
+
let h = m[1];
|
|
109
|
+
if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('');
|
|
110
|
+
if (h.length !== 6 && h.length !== 8) return null;
|
|
111
|
+
return {
|
|
112
|
+
r: parseInt(h.slice(0, 2), 16),
|
|
113
|
+
g: parseInt(h.slice(2, 4), 16),
|
|
114
|
+
b: parseInt(h.slice(4, 6), 16),
|
|
115
|
+
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
m = v.match(/^(rgba?|hsla?|oklch|oklab|lch|lab)\(([^()]+)\)$/);
|
|
120
|
+
if (!m) return null;
|
|
121
|
+
const parts = m[2].split(/[,/\s]+/).filter(Boolean);
|
|
122
|
+
if (parts.length < 3) return null;
|
|
123
|
+
const a = alphaOf(parts[3]);
|
|
124
|
+
let rgb;
|
|
125
|
+
switch (m[1]) {
|
|
126
|
+
case 'rgb':
|
|
127
|
+
case 'rgba':
|
|
128
|
+
rgb = parts.slice(0, 3).map((p) => num(p, 255));
|
|
129
|
+
break;
|
|
130
|
+
case 'hsl':
|
|
131
|
+
case 'hsla':
|
|
132
|
+
rgb = hslToRgb(parseFloat(parts[0]), num(parts[1].endsWith('%') ? parts[1] : parts[1] + '%'), num(parts[2].endsWith('%') ? parts[2] : parts[2] + '%'));
|
|
133
|
+
break;
|
|
134
|
+
case 'oklch': {
|
|
135
|
+
const L = num(parts[0]);
|
|
136
|
+
const C = num(parts[1], 0.4);
|
|
137
|
+
const H = (num(parts[2]) * Math.PI) / 180;
|
|
138
|
+
rgb = oklabToRgb(L, C * Math.cos(H), C * Math.sin(H));
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case 'oklab':
|
|
142
|
+
rgb = oklabToRgb(num(parts[0]), num(parts[1], 0.4), num(parts[2], 0.4));
|
|
143
|
+
break;
|
|
144
|
+
case 'lch': {
|
|
145
|
+
const C = num(parts[1], 150);
|
|
146
|
+
const H = (num(parts[2]) * Math.PI) / 180;
|
|
147
|
+
rgb = labToRgb(num(parts[0], 100), C * Math.cos(H), C * Math.sin(H));
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
case 'lab':
|
|
151
|
+
rgb = labToRgb(num(parts[0], 100), num(parts[1], 125), num(parts[2], 125));
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
if (rgb.some((x) => Number.isNaN(x))) return null;
|
|
155
|
+
return { r: rgb[0], g: rgb[1], b: rgb[2], a };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** '#rrggbb', alpha dropped. */
|
|
159
|
+
export function toHex({ r, g, b }) {
|
|
160
|
+
return '#' + toHex2(r) + toHex2(g) + toHex2(b);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** '#rrggbb', or '#rrggbbaa' when the colour is translucent. */
|
|
164
|
+
export function toHexAlpha(c) {
|
|
165
|
+
return c.a < 1 ? toHex(c) + toHex2(c.a * 255) : toHex(c);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 0 for a grey, 1 for a fully saturated colour. */
|
|
169
|
+
export function chroma({ r, g, b }) {
|
|
170
|
+
return (Math.max(r, g, b) - Math.min(r, g, b)) / 255;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** WCAG relative luminance, 0 (black) to 1 (white). */
|
|
174
|
+
export function luminance({ r, g, b }) {
|
|
175
|
+
const lin = (x) => {
|
|
176
|
+
const v = x / 255;
|
|
177
|
+
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
178
|
+
};
|
|
179
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Euclidean distance in RGB, 0 to ~441. */
|
|
183
|
+
export function distance(x, y) {
|
|
184
|
+
return Math.hypot(x.r - y.r, x.g - y.g, x.b - y.b);
|
|
185
|
+
}
|