differix 0.1.0a1__py3-none-any.whl
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.
- agents/editor.py +455 -0
- agents/researcher.py +405 -0
- agents/reviewer.py +374 -0
- cli/model_cli.py +430 -0
- config.py +557 -0
- differix-0.1.0a1.dist-info/METADATA +123 -0
- differix-0.1.0a1.dist-info/RECORD +62 -0
- differix-0.1.0a1.dist-info/WHEEL +5 -0
- differix-0.1.0a1.dist-info/entry_points.txt +2 -0
- differix-0.1.0a1.dist-info/top_level.txt +6 -0
- main.py +2796 -0
- memory/__init__.py +59 -0
- memory/embeddings/__init__.py +29 -0
- memory/embeddings/factory.py +178 -0
- memory/embeddings/providers.py +259 -0
- memory/manager.py +186 -0
- memory/migrator.py +253 -0
- memory/ranker.py +203 -0
- memory/schemas.py +127 -0
- memory/storage.py +552 -0
- tools/agents_chat.py +152 -0
- tools/asset_extractor.py +545 -0
- tools/asset_manager.py +115 -0
- tools/atomic_changeset.py +264 -0
- tools/backup_manager.py +122 -0
- tools/changelog_engine.py +261 -0
- tools/coherence_checker.py +771 -0
- tools/compiler.py +531 -0
- tools/component_normalizer.py +130 -0
- tools/context_assembler.py +631 -0
- tools/contract_validator.py +478 -0
- tools/cost_predictor.py +141 -0
- tools/drift_guard.py +196 -0
- tools/file_manager.py +119 -0
- tools/handshake.py +351 -0
- tools/lesson_manager.py +570 -0
- tools/lesson_memory_bridge.py +398 -0
- tools/llm_client.py +103 -0
- tools/llm_selector.py +362 -0
- tools/model_ranker.py +535 -0
- tools/palette_extractor.py +1043 -0
- tools/plugin_manager.py +204 -0
- tools/project_context_gatherer.py +404 -0
- tools/project_scanner.py +41 -0
- tools/project_structure.py +244 -0
- tools/reasoning_logger.py +78 -0
- tools/reflection.py +452 -0
- tools/sandbox_utils.py +56 -0
- tools/security/__init__.py +0 -0
- tools/security/behavior_guard.py +83 -0
- tools/security/verification_firewall.py +113 -0
- tools/security_filter.py +213 -0
- tools/session_adr.py +311 -0
- tools/skill_manager.py +129 -0
- tools/state_manager.py +383 -0
- tools/structure_index.py +570 -0
- tools/test_schema_consistency.py +19 -0
- tools/verification_firewall.py +113 -0
- tools/visual_capture.py +65 -0
- tools/visual_diff.py +220 -0
- tools/visual_reviewer.py +79 -0
- tools/web_search.py +371 -0
|
@@ -0,0 +1,1043 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tools/palette_extractor.py
|
|
3
|
+
from plugin: webdev
|
|
4
|
+
--------------------------
|
|
5
|
+
Extract a DifferiX-compatible palette from a URL or a local HTML file.
|
|
6
|
+
|
|
7
|
+
Given a web page, this tool:
|
|
8
|
+
1. Fetches the HTML (SSRF-guarded) and any linked stylesheets.
|
|
9
|
+
2. Extracts every color, font-family, radius, and transition it finds.
|
|
10
|
+
3. Clusters colors by role: backgrounds, accents, text, borders.
|
|
11
|
+
4. Emits a `palettes/<name>.css` file that conforms to the DifferiX
|
|
12
|
+
palette contract (see skills/webdev.md).
|
|
13
|
+
|
|
14
|
+
The output is not a copy of the source CSS. It is a new file with our
|
|
15
|
+
variable names, filled with values derived from the source's visual
|
|
16
|
+
identity. Colors and fonts are not copyrightable; the file we write
|
|
17
|
+
contains none of the source's code.
|
|
18
|
+
|
|
19
|
+
Public API:
|
|
20
|
+
extract_palette(source, output_dir="palettes", name=None,
|
|
21
|
+
dry_run=False) -> PaletteResult
|
|
22
|
+
|
|
23
|
+
CLI:
|
|
24
|
+
python tools/palette_extractor.py <url-or-file> [-o DIR] [--name NAME] [--dry-run]
|
|
25
|
+
|
|
26
|
+
Never raises on network failure — returns a PaletteResult with
|
|
27
|
+
`success=False` and an `error` string. The caller decides what to do.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import logging
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import sys
|
|
37
|
+
import time
|
|
38
|
+
import urllib.parse
|
|
39
|
+
import urllib.request
|
|
40
|
+
from dataclasses import dataclass, field, asdict
|
|
41
|
+
from html.parser import HTMLParser
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Dict, List, Optional, Tuple
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Lazy imports for optional / internal dependencies
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
def _import_web_search():
|
|
52
|
+
"""Import the SSRF guards from web_search.py (same project)."""
|
|
53
|
+
try:
|
|
54
|
+
from tools import web_search as _ws
|
|
55
|
+
return _ws
|
|
56
|
+
except ImportError:
|
|
57
|
+
try:
|
|
58
|
+
import web_search as _ws # type: ignore
|
|
59
|
+
return _ws
|
|
60
|
+
except ImportError:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Constants
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
# A small named-color table. Only the common ones — the full CSS list is
|
|
69
|
+
# 148 entries and provides negligible additional value.
|
|
70
|
+
_NAMED_COLORS: Dict[str, Tuple[int, int, int]] = {
|
|
71
|
+
"black": (0, 0, 0), "white": (255, 255, 255),
|
|
72
|
+
"red": (255, 0, 0), "green": (0, 128, 0), "blue": (0, 0, 255),
|
|
73
|
+
"yellow": (255, 255, 0), "cyan": (0, 255, 255), "magenta": (255, 0, 255),
|
|
74
|
+
"gray": (128, 128, 128), "grey": (128, 128, 128),
|
|
75
|
+
"silver": (192, 192, 192), "maroon": (128, 0, 0),
|
|
76
|
+
"olive": (128, 128, 0), "lime": (0, 255, 0),
|
|
77
|
+
"aqua": (0, 255, 255), "teal": (0, 128, 128),
|
|
78
|
+
"navy": (0, 0, 128), "fuchsia": (255, 0, 255),
|
|
79
|
+
"purple": (128, 0, 128), "orange": (255, 165, 0),
|
|
80
|
+
"pink": (255, 192, 203), "brown": (165, 42, 42),
|
|
81
|
+
"gold": (255, 215, 0), "indigo": (75, 0, 130),
|
|
82
|
+
"crimson": (220, 20, 60), "coral": (255, 127, 80),
|
|
83
|
+
"salmon": (250, 128, 114), "tomato": (255, 99, 71),
|
|
84
|
+
"khaki": (240, 230, 140), "lavender": (230, 230, 250),
|
|
85
|
+
"beige": (245, 245, 220), "ivory": (255, 255, 240),
|
|
86
|
+
"transparent": (0, 0, 0),
|
|
87
|
+
"currentcolor": (0, 0, 0), "inherit": (0, 0, 0),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Colors that should never be treated as accent candidates, even if they
|
|
91
|
+
# appear frequently. These are almost always utility or reset colors.
|
|
92
|
+
_IGNORE_AS_ACCENT = {
|
|
93
|
+
(0, 0, 0), (255, 255, 255),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# Frequency threshold — colors appearing fewer times than this are
|
|
97
|
+
# treated as one-off and excluded from the main clustering. Set to 2
|
|
98
|
+
# so that a color must appear in at least two rules to count.
|
|
99
|
+
_MIN_OCCURRENCES = 2
|
|
100
|
+
|
|
101
|
+
# CSS regexes — deliberately conservative. We would rather miss a color
|
|
102
|
+
# than misclassify a selector as a color.
|
|
103
|
+
_RE_HEX = re.compile(
|
|
104
|
+
r"#([0-9a-fA-F]{3,8})\b"
|
|
105
|
+
)
|
|
106
|
+
_RE_RGB = re.compile(
|
|
107
|
+
r"rgba?\(\s*"
|
|
108
|
+
r"([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)"
|
|
109
|
+
r"(?:\s*,\s*([\d.]+))?\s*\)",
|
|
110
|
+
re.IGNORECASE,
|
|
111
|
+
)
|
|
112
|
+
_RE_HSL = re.compile(
|
|
113
|
+
r"hsla?\(\s*"
|
|
114
|
+
r"([\d.]+)(?:deg)?\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%"
|
|
115
|
+
r"(?:\s*,\s*([\d.]+))?\s*\)",
|
|
116
|
+
re.IGNORECASE,
|
|
117
|
+
)
|
|
118
|
+
_RE_FONT_FAMILY = re.compile(
|
|
119
|
+
r"font-family\s*:\s*([^;}\n]+)",
|
|
120
|
+
re.IGNORECASE,
|
|
121
|
+
)
|
|
122
|
+
_RE_RULE = re.compile(
|
|
123
|
+
r"([^{}]+)\{([^{}]*)\}",
|
|
124
|
+
re.DOTALL,
|
|
125
|
+
)
|
|
126
|
+
_RE_CUSTOM_PROP = re.compile(
|
|
127
|
+
r"(--[a-zA-Z][\w-]*)\s*:\s*([^;}\n]+)",
|
|
128
|
+
)
|
|
129
|
+
_RE_STYLE_BLOCK = re.compile(
|
|
130
|
+
r"<style[^>]*>(.*?)</style>",
|
|
131
|
+
re.DOTALL | re.IGNORECASE,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# Data structures
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
@dataclass
|
|
139
|
+
class PaletteResult:
|
|
140
|
+
success: bool
|
|
141
|
+
palette_id: str = ""
|
|
142
|
+
output_path: str = ""
|
|
143
|
+
css_content: str = ""
|
|
144
|
+
colors_detected: int = 0
|
|
145
|
+
fonts_detected: int = 0
|
|
146
|
+
is_dark_mode: bool = False
|
|
147
|
+
error: str = ""
|
|
148
|
+
source: str = ""
|
|
149
|
+
|
|
150
|
+
def as_dict(self) -> Dict:
|
|
151
|
+
return asdict(self)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class PaletteExtractionError(Exception):
|
|
155
|
+
"""Raised for unrecoverable errors during extraction."""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
# Color utilities
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
def parse_color(s: str) -> Optional[Tuple[int, int, int]]:
|
|
162
|
+
"""
|
|
163
|
+
Parse a color string into an (R, G, B) tuple. Returns None on failure.
|
|
164
|
+
Accepts #rgb, #rrggbb, #rrggbbaa, rgb(), rgba(), hsl(), hsla(), and
|
|
165
|
+
the subset of CSS named colors in _NAMED_COLORS.
|
|
166
|
+
"""
|
|
167
|
+
if not s:
|
|
168
|
+
return None
|
|
169
|
+
s = s.strip().lower()
|
|
170
|
+
|
|
171
|
+
# Named
|
|
172
|
+
if s in _NAMED_COLORS:
|
|
173
|
+
return _NAMED_COLORS[s]
|
|
174
|
+
|
|
175
|
+
# Hex
|
|
176
|
+
if s.startswith("#"):
|
|
177
|
+
h = s[1:]
|
|
178
|
+
try:
|
|
179
|
+
if len(h) == 3:
|
|
180
|
+
return tuple(int(c * 2, 16) for c in h) # type: ignore
|
|
181
|
+
if len(h) == 6:
|
|
182
|
+
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
|
|
183
|
+
if len(h) == 8:
|
|
184
|
+
# ignore alpha
|
|
185
|
+
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
|
|
186
|
+
except ValueError:
|
|
187
|
+
return None
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
# rgb() / rgba()
|
|
191
|
+
m = _RE_RGB.fullmatch(s)
|
|
192
|
+
if m:
|
|
193
|
+
try:
|
|
194
|
+
return (
|
|
195
|
+
int(float(m.group(1))),
|
|
196
|
+
int(float(m.group(2))),
|
|
197
|
+
int(float(m.group(3))),
|
|
198
|
+
)
|
|
199
|
+
except (ValueError, TypeError):
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
# hsl() / hsla()
|
|
203
|
+
m = _RE_HSL.fullmatch(s)
|
|
204
|
+
if m:
|
|
205
|
+
try:
|
|
206
|
+
h = float(m.group(1))
|
|
207
|
+
s_pct = float(m.group(2)) / 100.0
|
|
208
|
+
l_pct = float(m.group(3)) / 100.0
|
|
209
|
+
return hsl_to_rgb(h, s_pct, l_pct)
|
|
210
|
+
except (ValueError, TypeError):
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def rgb_to_hsl(rgb: Tuple[int, int, int]) -> Tuple[float, float, float]:
|
|
217
|
+
"""Return (H in [0,360), S in [0,1], L in [0,1])."""
|
|
218
|
+
r, g, b = [v / 255.0 for v in rgb]
|
|
219
|
+
mx = max(r, g, b)
|
|
220
|
+
mn = min(r, g, b)
|
|
221
|
+
l = (mx + mn) / 2.0
|
|
222
|
+
if mx == mn:
|
|
223
|
+
return (0.0, 0.0, l)
|
|
224
|
+
d = mx - mn
|
|
225
|
+
s = d / (2.0 - mx - mn) if l > 0.5 else d / (mx + mn)
|
|
226
|
+
if mx == r:
|
|
227
|
+
h = ((g - b) / d) % 6.0
|
|
228
|
+
elif mx == g:
|
|
229
|
+
h = (b - r) / d + 2.0
|
|
230
|
+
else:
|
|
231
|
+
h = (r - g) / d + 4.0
|
|
232
|
+
return (h * 60.0, s, l)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def hsl_to_rgb(h: float, s: float, l: float) -> Tuple[int, int, int]:
|
|
236
|
+
h = (h % 360.0) / 360.0
|
|
237
|
+
s = max(0.0, min(1.0, s))
|
|
238
|
+
l = max(0.0, min(1.0, l))
|
|
239
|
+
if s == 0.0:
|
|
240
|
+
v = int(round(l * 255.0))
|
|
241
|
+
return (v, v, v)
|
|
242
|
+
q = l * (1 + s) if l < 0.5 else l + s - l * s
|
|
243
|
+
p = 2 * l - q
|
|
244
|
+
|
|
245
|
+
def hue(t: float) -> float:
|
|
246
|
+
t = t % 1.0
|
|
247
|
+
if t < 1 / 6:
|
|
248
|
+
return p + (q - p) * 6 * t
|
|
249
|
+
if t < 1 / 2:
|
|
250
|
+
return q
|
|
251
|
+
if t < 2 / 3:
|
|
252
|
+
return p + (q - p) * (2 / 3 - t) * 6
|
|
253
|
+
return p
|
|
254
|
+
|
|
255
|
+
r = int(round(hue(h + 1 / 3) * 255))
|
|
256
|
+
g = int(round(hue(h) * 255))
|
|
257
|
+
b = int(round(hue(h - 1 / 3) * 255))
|
|
258
|
+
return (max(0, min(255, r)), max(0, min(255, g)), max(0, min(255, b)))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def rgb_to_hex(rgb: Tuple[int, int, int]) -> str:
|
|
262
|
+
return "#{:02x}{:02x}{:02x}".format(*rgb)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def rgba_string(rgb: Tuple[int, int, int], alpha: float) -> str:
|
|
266
|
+
a = max(0.0, min(1.0, alpha))
|
|
267
|
+
return f"rgba({rgb[0]}, {rgb[1]}, {rgb[2]}, {a:.2f})"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def luminance(rgb: Tuple[int, int, int]) -> float:
|
|
271
|
+
"""Relative luminance approximation in [0, 1]."""
|
|
272
|
+
r, g, b = rgb
|
|
273
|
+
return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _blend_toward(
|
|
277
|
+
color: Tuple[int, int, int],
|
|
278
|
+
target: Tuple[int, int, int],
|
|
279
|
+
t: float,
|
|
280
|
+
) -> Tuple[int, int, int]:
|
|
281
|
+
"""
|
|
282
|
+
Blend `color` toward `target` by factor `t` (0..1).
|
|
283
|
+
t=0 returns color, t=1 returns target.
|
|
284
|
+
"""
|
|
285
|
+
t = max(0.0, min(1.0, t))
|
|
286
|
+
r1, g1, b1 = color
|
|
287
|
+
r2, g2, b2 = target
|
|
288
|
+
return (
|
|
289
|
+
int(round(r1 * (1 - t) + r2 * t)),
|
|
290
|
+
int(round(g1 * (1 - t) + g2 * t)),
|
|
291
|
+
int(round(b1 * (1 - t) + b2 * t)),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def adjust_lightness(rgb: Tuple[int, int, int], delta: float) -> Tuple[int, int, int]:
|
|
296
|
+
"""Shift the L channel of an RGB color by delta (positive = lighter)."""
|
|
297
|
+
h, s, l = rgb_to_hsl(rgb)
|
|
298
|
+
return hsl_to_rgb(h, s, max(0.0, min(1.0, l + delta)))
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ---------------------------------------------------------------------------
|
|
302
|
+
# Fetching
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
def _is_remote(source: str) -> bool:
|
|
305
|
+
return source.startswith("http://") or source.startswith("https://")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _fetch_url(url: str, timeout: float = 15.0) -> str:
|
|
309
|
+
"""SSRF-guarded fetch. Returns the raw HTML."""
|
|
310
|
+
ws = _import_web_search()
|
|
311
|
+
if ws is None:
|
|
312
|
+
raise PaletteExtractionError(
|
|
313
|
+
"web_search module not importable — cannot fetch remote URLs"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
safe, reason = ws._is_url_safe(url) # type: ignore[attr-defined]
|
|
317
|
+
if not safe:
|
|
318
|
+
raise PaletteExtractionError(f"Blocked unsafe URL: {reason}")
|
|
319
|
+
|
|
320
|
+
opener = ws._build_safe_opener() # type: ignore[attr-defined]
|
|
321
|
+
req = urllib.request.Request(
|
|
322
|
+
url,
|
|
323
|
+
headers={
|
|
324
|
+
"User-Agent": "DifferiX/0.4 palette-extractor",
|
|
325
|
+
"Accept": "text/html,application/xhtml+xml",
|
|
326
|
+
},
|
|
327
|
+
)
|
|
328
|
+
try:
|
|
329
|
+
with opener.open(req, timeout=timeout) as response:
|
|
330
|
+
raw = ws._read_capped(response) # type: ignore[attr-defined]
|
|
331
|
+
except Exception as exc:
|
|
332
|
+
raise PaletteExtractionError(f"Failed to fetch {url}: {exc}") from exc
|
|
333
|
+
|
|
334
|
+
return raw.decode("utf-8", errors="replace")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _fetch_css(url: str, timeout: float = 10.0) -> str:
|
|
338
|
+
"""Fetch a linked stylesheet. Returns "" on failure (non-fatal)."""
|
|
339
|
+
ws = _import_web_search()
|
|
340
|
+
if ws is None:
|
|
341
|
+
return ""
|
|
342
|
+
safe, _ = ws._is_url_safe(url) # type: ignore[attr-defined]
|
|
343
|
+
if not safe:
|
|
344
|
+
return ""
|
|
345
|
+
opener = ws._build_safe_opener() # type: ignore[attr-defined]
|
|
346
|
+
req = urllib.request.Request(
|
|
347
|
+
url,
|
|
348
|
+
headers={"User-Agent": "DifferiX/0.4 palette-extractor"},
|
|
349
|
+
)
|
|
350
|
+
try:
|
|
351
|
+
with opener.open(req, timeout=timeout) as response:
|
|
352
|
+
raw = ws._read_capped(response, max_bytes=2_000_000) # type: ignore[attr-defined]
|
|
353
|
+
return raw.decode("utf-8", errors="replace")
|
|
354
|
+
except Exception as exc:
|
|
355
|
+
logger.debug("Skipped stylesheet %s: %s", url, exc)
|
|
356
|
+
return ""
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _read_local(path: str) -> str:
|
|
360
|
+
try:
|
|
361
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
362
|
+
return f.read()
|
|
363
|
+
except OSError as exc:
|
|
364
|
+
raise PaletteExtractionError(f"Failed to read {path}: {exc}") from exc
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# ---------------------------------------------------------------------------
|
|
368
|
+
# HTML parsing
|
|
369
|
+
# ---------------------------------------------------------------------------
|
|
370
|
+
class _StylesheetLinkCollector(HTMLParser):
|
|
371
|
+
"""Collects <link rel="stylesheet"> hrefs and inline <style> bodies."""
|
|
372
|
+
|
|
373
|
+
def __init__(self) -> None:
|
|
374
|
+
super().__init__()
|
|
375
|
+
self.link_hrefs: List[str] = []
|
|
376
|
+
self.inline_styles: List[str] = []
|
|
377
|
+
self._in_style = False
|
|
378
|
+
self._style_buffer: List[str] = []
|
|
379
|
+
|
|
380
|
+
def handle_starttag(self, tag: str, attrs) -> None:
|
|
381
|
+
tag_lower = tag.lower()
|
|
382
|
+
attr_dict = {k.lower(): (v or "") for k, v in attrs}
|
|
383
|
+
if tag_lower == "link":
|
|
384
|
+
rel = attr_dict.get("rel", "").lower()
|
|
385
|
+
href = attr_dict.get("href", "")
|
|
386
|
+
if "stylesheet" in rel and href:
|
|
387
|
+
self.link_hrefs.append(href)
|
|
388
|
+
elif tag_lower == "style":
|
|
389
|
+
self._in_style = True
|
|
390
|
+
self._style_buffer = []
|
|
391
|
+
|
|
392
|
+
def handle_endtag(self, tag: str) -> None:
|
|
393
|
+
if tag.lower() == "style" and self._in_style:
|
|
394
|
+
self.inline_styles.append("".join(self._style_buffer))
|
|
395
|
+
self._in_style = False
|
|
396
|
+
self._style_buffer = []
|
|
397
|
+
|
|
398
|
+
def handle_data(self, data: str) -> None:
|
|
399
|
+
if self._in_style:
|
|
400
|
+
self._style_buffer.append(data)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _collect_css(html: str, base_url: Optional[str]) -> str:
|
|
404
|
+
"""
|
|
405
|
+
Combine all CSS sources: inline <style> blocks and linked stylesheets.
|
|
406
|
+
Returns a single concatenated CSS string.
|
|
407
|
+
"""
|
|
408
|
+
collector = _StylesheetLinkCollector()
|
|
409
|
+
try:
|
|
410
|
+
collector.feed(html)
|
|
411
|
+
except Exception as exc:
|
|
412
|
+
logger.warning("HTML parsing partial: %s", exc)
|
|
413
|
+
|
|
414
|
+
parts: List[str] = list(collector.inline_styles)
|
|
415
|
+
|
|
416
|
+
# Also do a regex pass for <style> in case HTMLParser missed something
|
|
417
|
+
parts.extend(_RE_STYLE_BLOCK.findall(html))
|
|
418
|
+
|
|
419
|
+
# Fetch linked stylesheets
|
|
420
|
+
for href in collector.link_hrefs:
|
|
421
|
+
if not base_url:
|
|
422
|
+
continue
|
|
423
|
+
full = urllib.parse.urljoin(base_url, href)
|
|
424
|
+
# Skip well-known font CDNs — they don't contain palette data
|
|
425
|
+
if any(cdn in full for cdn in (
|
|
426
|
+
"fonts.googleapis.com", "fonts.gstatic.com",
|
|
427
|
+
"use.typekit.net", "cdn.jsdelivr.net",
|
|
428
|
+
)):
|
|
429
|
+
continue
|
|
430
|
+
css = _fetch_css(full)
|
|
431
|
+
if css:
|
|
432
|
+
parts.append(css)
|
|
433
|
+
|
|
434
|
+
return "\n\n".join(parts)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# ---------------------------------------------------------------------------
|
|
438
|
+
# CSS parsing
|
|
439
|
+
# ---------------------------------------------------------------------------
|
|
440
|
+
def _extract_color_occurrences(css: str) -> Dict[str, int]:
|
|
441
|
+
"""
|
|
442
|
+
Return a mapping from normalized hex -> occurrence count.
|
|
443
|
+
Parses hex, rgb(), rgba(), hsl(), hsla(), and named colors.
|
|
444
|
+
"""
|
|
445
|
+
counts: Dict[str, int] = {}
|
|
446
|
+
|
|
447
|
+
# Hex
|
|
448
|
+
for m in _RE_HEX.finditer(css):
|
|
449
|
+
raw = "#" + m.group(1).lower()
|
|
450
|
+
rgb = parse_color(raw)
|
|
451
|
+
if rgb:
|
|
452
|
+
key = rgb_to_hex(rgb)
|
|
453
|
+
counts[key] = counts.get(key, 0) + 1
|
|
454
|
+
|
|
455
|
+
# rgb / rgba
|
|
456
|
+
for m in _RE_RGB.finditer(css):
|
|
457
|
+
raw = m.group(0)
|
|
458
|
+
rgb = parse_color(raw)
|
|
459
|
+
if rgb:
|
|
460
|
+
key = rgb_to_hex(rgb)
|
|
461
|
+
counts[key] = counts.get(key, 0) + 1
|
|
462
|
+
|
|
463
|
+
# hsl / hsla
|
|
464
|
+
for m in _RE_HSL.finditer(css):
|
|
465
|
+
raw = m.group(0)
|
|
466
|
+
rgb = parse_color(raw)
|
|
467
|
+
if rgb:
|
|
468
|
+
key = rgb_to_hex(rgb)
|
|
469
|
+
counts[key] = counts.get(key, 0) + 1
|
|
470
|
+
|
|
471
|
+
# Named colors — do a word-boundary search on the CSS
|
|
472
|
+
for name, rgb in _NAMED_COLORS.items():
|
|
473
|
+
pattern = re.compile(r"\b" + re.escape(name) + r"\b")
|
|
474
|
+
n = len(pattern.findall(css))
|
|
475
|
+
if n > 0 and name not in ("transparent", "currentcolor", "inherit"):
|
|
476
|
+
key = rgb_to_hex(rgb)
|
|
477
|
+
counts[key] = counts.get(key, 0) + n
|
|
478
|
+
|
|
479
|
+
return counts
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _extract_custom_properties(css: str) -> Dict[str, str]:
|
|
483
|
+
"""Extract --var: value pairs from any rule."""
|
|
484
|
+
out: Dict[str, str] = {}
|
|
485
|
+
for m in _RE_CUSTOM_PROP.finditer(css):
|
|
486
|
+
name = m.group(1).strip()
|
|
487
|
+
value = m.group(2).strip()
|
|
488
|
+
# Keep the first definition of a given variable
|
|
489
|
+
if name not in out:
|
|
490
|
+
out[name] = value
|
|
491
|
+
return out
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _extract_fonts_by_context(
|
|
495
|
+
css: str,
|
|
496
|
+
custom_props: Optional[Dict[str, str]] = None,
|
|
497
|
+
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
|
498
|
+
"""
|
|
499
|
+
Return (heading_font, body_font, mono_font) inferred from the CSS.
|
|
500
|
+
Resolves var() references against `custom_props` when provided.
|
|
501
|
+
"""
|
|
502
|
+
if custom_props is None:
|
|
503
|
+
custom_props = {}
|
|
504
|
+
|
|
505
|
+
heading_font: Optional[str] = None
|
|
506
|
+
body_font: Optional[str] = None
|
|
507
|
+
mono_font: Optional[str] = None
|
|
508
|
+
font_counter: Dict[str, int] = {}
|
|
509
|
+
|
|
510
|
+
for m in _RE_RULE.finditer(css):
|
|
511
|
+
selector = m.group(1).strip().lower()
|
|
512
|
+
body = m.group(2)
|
|
513
|
+
|
|
514
|
+
for fm in _RE_FONT_FAMILY.finditer(body):
|
|
515
|
+
family = fm.group(1).strip().rstrip(";").strip()
|
|
516
|
+
if not family:
|
|
517
|
+
continue
|
|
518
|
+
|
|
519
|
+
primary = _first_font_name(family)
|
|
520
|
+
if not primary:
|
|
521
|
+
# Maybe a bare var() reference — resolve it
|
|
522
|
+
primary = _resolve_font_var(family, custom_props)
|
|
523
|
+
if not primary:
|
|
524
|
+
continue
|
|
525
|
+
|
|
526
|
+
font_counter[primary] = font_counter.get(primary, 0) + 1
|
|
527
|
+
|
|
528
|
+
if "mono" in primary.lower() or "code" in primary.lower():
|
|
529
|
+
if mono_font is None:
|
|
530
|
+
mono_font = primary
|
|
531
|
+
|
|
532
|
+
if heading_font is None:
|
|
533
|
+
if any(s in selector for s in (
|
|
534
|
+
"h1", "h2", "h3", ".heading", ".title", ".headline",
|
|
535
|
+
)):
|
|
536
|
+
heading_font = primary
|
|
537
|
+
|
|
538
|
+
if font_counter:
|
|
539
|
+
sorted_fonts = sorted(
|
|
540
|
+
font_counter.items(),
|
|
541
|
+
key=lambda kv: (-kv[1], kv[0].lower()),
|
|
542
|
+
)
|
|
543
|
+
for name, _ in sorted_fonts:
|
|
544
|
+
if not _is_generic_font(name):
|
|
545
|
+
body_font = name
|
|
546
|
+
break
|
|
547
|
+
if body_font is None:
|
|
548
|
+
body_font = sorted_fonts[0][0]
|
|
549
|
+
|
|
550
|
+
# Resolve any remaining var() references
|
|
551
|
+
heading_font = _resolve_font_var(heading_font, custom_props)
|
|
552
|
+
body_font = _resolve_font_var(body_font, custom_props)
|
|
553
|
+
mono_font = _resolve_font_var(mono_font, custom_props)
|
|
554
|
+
|
|
555
|
+
return (heading_font, body_font, mono_font)
|
|
556
|
+
|
|
557
|
+
_GENERIC_FONT_NAMES = {
|
|
558
|
+
"serif", "sans-serif", "monospace", "cursive", "fantasy",
|
|
559
|
+
"system-ui", "-apple-system", "blinkmacsystemfont",
|
|
560
|
+
"inherit", "initial", "unset",
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _first_font_name(family_list: str) -> Optional[str]:
|
|
565
|
+
"""
|
|
566
|
+
Given `"Inter", system-ui, sans-serif`, return `Inter`.
|
|
567
|
+
Rejects CSS variable references — they are not font names.
|
|
568
|
+
"""
|
|
569
|
+
parts = [p.strip().strip("'\"") for p in family_list.split(",")]
|
|
570
|
+
for p in parts:
|
|
571
|
+
p = p.strip()
|
|
572
|
+
if not p:
|
|
573
|
+
continue
|
|
574
|
+
# Reject CSS variable references — the caller must resolve them
|
|
575
|
+
if p.startswith("var(") or p.startswith("--"):
|
|
576
|
+
continue
|
|
577
|
+
if p.lower() in _GENERIC_FONT_NAMES:
|
|
578
|
+
continue
|
|
579
|
+
return p
|
|
580
|
+
return parts[0] if parts and parts[0] else None
|
|
581
|
+
|
|
582
|
+
def _resolve_font_var(name: Optional[str], custom_props: Dict[str, str]) -> Optional[str]:
|
|
583
|
+
"""
|
|
584
|
+
If `name` is a bare `var(--foo)` reference, resolve it against
|
|
585
|
+
custom_props and return the first concrete font name inside.
|
|
586
|
+
Returns None when resolution fails.
|
|
587
|
+
"""
|
|
588
|
+
if not name:
|
|
589
|
+
return None
|
|
590
|
+
m = re.match(r"var\(\s*(--[\w-]+)\s*\)", name.strip())
|
|
591
|
+
if not m:
|
|
592
|
+
return name
|
|
593
|
+
ref = m.group(1)
|
|
594
|
+
resolved_value = custom_props.get(ref)
|
|
595
|
+
if not resolved_value:
|
|
596
|
+
return None
|
|
597
|
+
# resolved_value is itself a font-family list — take the first concrete name
|
|
598
|
+
return _first_font_name(resolved_value)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _is_generic_font(name: str) -> bool:
|
|
602
|
+
return name.lower() in _GENERIC_FONT_NAMES
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
# ---------------------------------------------------------------------------
|
|
606
|
+
# Clustering
|
|
607
|
+
# ---------------------------------------------------------------------------
|
|
608
|
+
def _classify_colors(
|
|
609
|
+
color_counts: Dict[str, int],
|
|
610
|
+
) -> Tuple[bool, List[str], List[str], List[str], List[str]]:
|
|
611
|
+
"""
|
|
612
|
+
Given a hex->count mapping, return:
|
|
613
|
+
(is_dark_mode, backgrounds, accents, text_colors, neutrals)
|
|
614
|
+
Each list is sorted by descending frequency.
|
|
615
|
+
"""
|
|
616
|
+
# Filter: require minimum occurrence
|
|
617
|
+
filtered = [
|
|
618
|
+
(hex_str, parse_color(hex_str), count)
|
|
619
|
+
for hex_str, count in color_counts.items()
|
|
620
|
+
]
|
|
621
|
+
filtered = [(h, rgb, c) for h, rgb, c in filtered if rgb is not None and c >= _MIN_OCCURRENCES]
|
|
622
|
+
|
|
623
|
+
if not filtered:
|
|
624
|
+
# Fallback to all colors if nothing meets the threshold
|
|
625
|
+
filtered = [
|
|
626
|
+
(hex_str, parse_color(hex_str), count)
|
|
627
|
+
for hex_str, count in color_counts.items()
|
|
628
|
+
]
|
|
629
|
+
filtered = [(h, rgb, c) for h, rgb, c in filtered if rgb is not None]
|
|
630
|
+
|
|
631
|
+
if not filtered:
|
|
632
|
+
return (True, [], [], [], [])
|
|
633
|
+
|
|
634
|
+
# Sort by count desc
|
|
635
|
+
filtered.sort(key=lambda t: -t[2])
|
|
636
|
+
|
|
637
|
+
# Determine mode from the top colors
|
|
638
|
+
top = filtered[:5]
|
|
639
|
+
avg_lum = sum(luminance(rgb) for _, rgb, _ in top) / len(top)
|
|
640
|
+
is_dark = avg_lum < 0.5
|
|
641
|
+
|
|
642
|
+
backgrounds: List[str] = []
|
|
643
|
+
accents: List[str] = []
|
|
644
|
+
text_colors: List[str] = []
|
|
645
|
+
neutrals: List[str] = []
|
|
646
|
+
|
|
647
|
+
for hex_str, rgb, _ in filtered:
|
|
648
|
+
h, s, l = rgb_to_hsl(rgb)
|
|
649
|
+
lum = luminance(rgb)
|
|
650
|
+
|
|
651
|
+
is_chromatic = s > 0.20
|
|
652
|
+
|
|
653
|
+
if is_chromatic:
|
|
654
|
+
# Chromatic colors become accent candidates if reasonably saturated
|
|
655
|
+
if s > 0.35 and rgb not in _IGNORE_AS_ACCENT:
|
|
656
|
+
accents.append(hex_str)
|
|
657
|
+
else:
|
|
658
|
+
neutrals.append(hex_str)
|
|
659
|
+
else:
|
|
660
|
+
# Neutral: classify by luminance relative to the theme
|
|
661
|
+
if is_dark:
|
|
662
|
+
if lum < 0.12:
|
|
663
|
+
backgrounds.append(hex_str)
|
|
664
|
+
elif lum > 0.75:
|
|
665
|
+
text_colors.append(hex_str)
|
|
666
|
+
else:
|
|
667
|
+
neutrals.append(hex_str)
|
|
668
|
+
else:
|
|
669
|
+
if lum > 0.92:
|
|
670
|
+
backgrounds.append(hex_str)
|
|
671
|
+
elif lum < 0.25:
|
|
672
|
+
text_colors.append(hex_str)
|
|
673
|
+
else:
|
|
674
|
+
neutrals.append(hex_str)
|
|
675
|
+
|
|
676
|
+
return (is_dark, backgrounds, accents, text_colors, neutrals)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
# ---------------------------------------------------------------------------
|
|
680
|
+
# Palette construction
|
|
681
|
+
# ---------------------------------------------------------------------------
|
|
682
|
+
def _pick(lst: List[str], fallback: str, idx: int = 0) -> str:
|
|
683
|
+
if len(lst) > idx:
|
|
684
|
+
return lst[idx]
|
|
685
|
+
if lst:
|
|
686
|
+
return lst[-1]
|
|
687
|
+
return fallback
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _match_custom_props_to_roles(props: Dict[str, str]) -> Dict[str, str]:
|
|
691
|
+
"""
|
|
692
|
+
Try to guess the role of each custom property from its name, and map
|
|
693
|
+
it to our token names. Returns {our_token: hex_value} for matches.
|
|
694
|
+
Only includes matches where the value parses to a color.
|
|
695
|
+
"""
|
|
696
|
+
role_to_hex: Dict[str, str] = {}
|
|
697
|
+
|
|
698
|
+
def _value_to_hex(v: str) -> Optional[str]:
|
|
699
|
+
rgb = parse_color(v.strip())
|
|
700
|
+
return rgb_to_hex(rgb) if rgb else None
|
|
701
|
+
|
|
702
|
+
patterns: List[Tuple[str, List[str]]] = [
|
|
703
|
+
("accent", ["primary", "brand", "accent", "main", "highlight", "link"]),
|
|
704
|
+
("bg-primary", ["bg", "background", "surface", "base", "body"]),
|
|
705
|
+
("text-main", ["text", "foreground", "fg", "content", "primary-text"]),
|
|
706
|
+
("border-subtle", ["border", "divider", "outline"]),
|
|
707
|
+
]
|
|
708
|
+
|
|
709
|
+
for our_token, keywords in patterns:
|
|
710
|
+
for name, value in props.items():
|
|
711
|
+
lname = name.lower().lstrip("-")
|
|
712
|
+
if any(kw in lname for kw in keywords):
|
|
713
|
+
hex_val = _value_to_hex(value)
|
|
714
|
+
if hex_val and our_token not in role_to_hex:
|
|
715
|
+
role_to_hex[our_token] = hex_val
|
|
716
|
+
break
|
|
717
|
+
|
|
718
|
+
return role_to_hex
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def build_palette(
|
|
722
|
+
color_counts: Dict[str, int],
|
|
723
|
+
fonts: Tuple[Optional[str], Optional[str], Optional[str]],
|
|
724
|
+
custom_props: Dict[str, str],
|
|
725
|
+
) -> Tuple[Dict[str, str], bool]:
|
|
726
|
+
"""
|
|
727
|
+
Build the palette dictionary. Returns (palette, is_dark_mode).
|
|
728
|
+
"""
|
|
729
|
+
is_dark, bgs, accents, texts, neutrals = _classify_colors(color_counts)
|
|
730
|
+
hinted = _match_custom_props_to_roles(custom_props)
|
|
731
|
+
|
|
732
|
+
# ---- Backgrounds ----
|
|
733
|
+
if is_dark:
|
|
734
|
+
bg_default = "#0a0c10"
|
|
735
|
+
bg_secondary_default = "#12161f"
|
|
736
|
+
bg_tertiary_default = "#181d28"
|
|
737
|
+
bg_card_default = "#141821"
|
|
738
|
+
text_default = "#f3f4f6"
|
|
739
|
+
text_muted_default = "#9ca3af"
|
|
740
|
+
text_dim_default = "#6b7280"
|
|
741
|
+
text_inverse_default = "#0a0c10"
|
|
742
|
+
else:
|
|
743
|
+
bg_default = "#ffffff"
|
|
744
|
+
bg_secondary_default = "#f5f5f7"
|
|
745
|
+
bg_tertiary_default = "#e8e8ec"
|
|
746
|
+
bg_card_default = "#ffffff"
|
|
747
|
+
text_default = "#111318"
|
|
748
|
+
text_muted_default = "#4b5563"
|
|
749
|
+
text_dim_default = "#6b7280"
|
|
750
|
+
text_inverse_default = "#ffffff"
|
|
751
|
+
|
|
752
|
+
bg_primary = hinted.get("bg-primary") or _pick(bgs, bg_default, 0)
|
|
753
|
+
bg_secondary = _pick(bgs, bg_secondary_default, 1) if len(bgs) > 1 else bg_secondary_default
|
|
754
|
+
bg_tertiary = _pick(bgs, bg_tertiary_default, 2) if len(bgs) > 2 else bg_tertiary_default
|
|
755
|
+
|
|
756
|
+
# ---- Accent ----
|
|
757
|
+
accent_hex = hinted.get("accent") or _pick(accents, "#f59e0b", 0)
|
|
758
|
+
accent_rgb = parse_color(accent_hex) or (245, 158, 11)
|
|
759
|
+
accent_light_rgb = adjust_lightness(accent_rgb, 0.12)
|
|
760
|
+
accent_dark_rgb = adjust_lightness(accent_rgb, -0.12)
|
|
761
|
+
|
|
762
|
+
# ---- Text ----
|
|
763
|
+
text_main_hex = hinted.get("text-main") or _pick(texts, text_default, 0)
|
|
764
|
+
text_main_rgb = parse_color(text_main_hex) or parse_color(text_default) or (243, 244, 246)
|
|
765
|
+
|
|
766
|
+
bg_rgb = parse_color(bg_primary) or ((10, 12, 16) if is_dark else (255, 255, 255))
|
|
767
|
+
|
|
768
|
+
# Derive muted and dim by blending the main text color toward the
|
|
769
|
+
# background. This works in both light and dark mode and never
|
|
770
|
+
# collapses to a single value, which adjust_lightness did when the
|
|
771
|
+
# starting color was already at lightness 0 or 1.
|
|
772
|
+
text_muted_rgb = _blend_toward(text_main_rgb, bg_rgb, 0.45)
|
|
773
|
+
text_dim_rgb = _blend_toward(text_main_rgb, bg_rgb, 0.65)
|
|
774
|
+
|
|
775
|
+
# ---- Borders ----
|
|
776
|
+
border_subtle = hinted.get("border-subtle") or rgba_string(text_main_rgb, 0.08)
|
|
777
|
+
border_medium = rgba_string(text_main_rgb, 0.16)
|
|
778
|
+
border_accent = rgba_string(accent_rgb, 0.35)
|
|
779
|
+
|
|
780
|
+
# ---- Fonts ----
|
|
781
|
+
heading_font, body_font, mono_font = fonts
|
|
782
|
+
heading_font = heading_font or body_font or "Space Grotesk"
|
|
783
|
+
body_font = body_font or "Inter"
|
|
784
|
+
mono_font = mono_font or "JetBrains Mono"
|
|
785
|
+
|
|
786
|
+
# ---- Inverse text (used on top of the accent surface) ----
|
|
787
|
+
accent_lum = luminance(accent_rgb)
|
|
788
|
+
text_inverse_hex = "#0a0c10" if accent_lum > 0.5 else "#ffffff"
|
|
789
|
+
|
|
790
|
+
palette: Dict[str, str] = {
|
|
791
|
+
"--bg-primary": bg_primary,
|
|
792
|
+
"--bg-secondary": bg_secondary,
|
|
793
|
+
"--bg-tertiary": bg_tertiary,
|
|
794
|
+
"--bg-card": bg_card_default,
|
|
795
|
+
"--bg-glass": rgba_string(bg_rgb, 0.72),
|
|
796
|
+
|
|
797
|
+
"--accent": rgb_to_hex(accent_rgb),
|
|
798
|
+
"--accent-light": rgb_to_hex(accent_light_rgb),
|
|
799
|
+
"--accent-dark": rgb_to_hex(accent_dark_rgb),
|
|
800
|
+
"--accent-glow": rgba_string(accent_rgb, 0.25),
|
|
801
|
+
"--accent-soft": rgba_string(accent_rgb, 0.10),
|
|
802
|
+
|
|
803
|
+
"--text-main": rgb_to_hex(text_main_rgb),
|
|
804
|
+
"--text-muted": rgb_to_hex(text_muted_rgb),
|
|
805
|
+
"--text-dim": rgb_to_hex(text_dim_rgb),
|
|
806
|
+
"--text-inverse": text_inverse_hex,
|
|
807
|
+
|
|
808
|
+
"--border-subtle": border_subtle,
|
|
809
|
+
"--border-medium": border_medium,
|
|
810
|
+
"--border-accent": border_accent,
|
|
811
|
+
|
|
812
|
+
"--font-heading": f"'{heading_font}', system-ui, sans-serif",
|
|
813
|
+
"--font-body": f"'{body_font}', system-ui, sans-serif",
|
|
814
|
+
"--font-mono": f"'{mono_font}', monospace",
|
|
815
|
+
|
|
816
|
+
"--transition-fast": "0.15s cubic-bezier(0.16, 1, 0.3, 1)",
|
|
817
|
+
"--transition-smooth": "0.3s cubic-bezier(0.16, 1, 0.3, 1)",
|
|
818
|
+
}
|
|
819
|
+
return (palette, is_dark)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
# ---------------------------------------------------------------------------
|
|
823
|
+
# Output
|
|
824
|
+
# ---------------------------------------------------------------------------
|
|
825
|
+
def _slug_from_source(source: str) -> str:
|
|
826
|
+
if _is_remote(source):
|
|
827
|
+
try:
|
|
828
|
+
host = urllib.parse.urlparse(source).hostname or ""
|
|
829
|
+
except Exception:
|
|
830
|
+
host = source
|
|
831
|
+
host = host.replace("www.", "")
|
|
832
|
+
slug = re.sub(r"[^a-z0-9]+", "-", host.lower()).strip("-")
|
|
833
|
+
return slug or "extracted"
|
|
834
|
+
else:
|
|
835
|
+
base = os.path.splitext(os.path.basename(source))[0]
|
|
836
|
+
slug = re.sub(r"[^a-z0-9]+", "-", base.lower()).strip("-")
|
|
837
|
+
return slug or "extracted"
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _render_palette_css(
|
|
841
|
+
palette: Dict[str, str],
|
|
842
|
+
name: str,
|
|
843
|
+
source: str,
|
|
844
|
+
is_dark: bool,
|
|
845
|
+
) -> str:
|
|
846
|
+
mood = "dark, high-contrast" if is_dark else "light, editorial"
|
|
847
|
+
groups = [
|
|
848
|
+
("Surfaces", ["--bg-primary", "--bg-secondary", "--bg-tertiary",
|
|
849
|
+
"--bg-card", "--bg-glass"]),
|
|
850
|
+
("Accent", ["--accent", "--accent-light", "--accent-dark",
|
|
851
|
+
"--accent-glow", "--accent-soft"]),
|
|
852
|
+
("Text", ["--text-main", "--text-muted", "--text-dim",
|
|
853
|
+
"--text-inverse"]),
|
|
854
|
+
("Borders", ["--border-subtle", "--border-medium", "--border-accent"]),
|
|
855
|
+
("Typography", ["--font-heading", "--font-body", "--font-mono"]),
|
|
856
|
+
("Motion", ["--transition-fast", "--transition-smooth"]),
|
|
857
|
+
]
|
|
858
|
+
|
|
859
|
+
lines = [
|
|
860
|
+
f"/* Palette: {name}",
|
|
861
|
+
f" Mood: {mood}",
|
|
862
|
+
f" Source: {source}",
|
|
863
|
+
f" Extracted: {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}",
|
|
864
|
+
f"",
|
|
865
|
+
f" This file was generated by tools/palette_extractor.py.",
|
|
866
|
+
f" The values are derived from the source's visual identity —",
|
|
867
|
+
f" they are not copied from the source's CSS. Edit freely.",
|
|
868
|
+
f"*/",
|
|
869
|
+
"",
|
|
870
|
+
":root {",
|
|
871
|
+
]
|
|
872
|
+
|
|
873
|
+
for i, (label, keys) in enumerate(groups):
|
|
874
|
+
if i > 0:
|
|
875
|
+
lines.append("")
|
|
876
|
+
lines.append(f" /* {label} */")
|
|
877
|
+
for key in keys:
|
|
878
|
+
# Pad `key + ':'` to 22 chars so values align
|
|
879
|
+
padded = f"{key}:".ljust(22)
|
|
880
|
+
lines.append(f" {padded} {palette[key]};")
|
|
881
|
+
|
|
882
|
+
lines.append("}")
|
|
883
|
+
lines.append("")
|
|
884
|
+
return "\n".join(lines)
|
|
885
|
+
|
|
886
|
+
def extract_palette(
|
|
887
|
+
source: str,
|
|
888
|
+
output_dir: str = "palettes",
|
|
889
|
+
name: Optional[str] = None,
|
|
890
|
+
dry_run: bool = False,
|
|
891
|
+
) -> PaletteResult:
|
|
892
|
+
"""
|
|
893
|
+
Extract a palette from a URL or local HTML file.
|
|
894
|
+
|
|
895
|
+
Args:
|
|
896
|
+
source: URL or path to local HTML file.
|
|
897
|
+
output_dir: directory to write the palette file (created if missing).
|
|
898
|
+
name: palette slug. Defaults to a slug derived from the source.
|
|
899
|
+
dry_run: if True, don't write to disk — just return the CSS in the
|
|
900
|
+
result.
|
|
901
|
+
|
|
902
|
+
Returns:
|
|
903
|
+
PaletteResult. Never raises for network or parse errors; check
|
|
904
|
+
`.success` and `.error` fields.
|
|
905
|
+
"""
|
|
906
|
+
t0 = time.time()
|
|
907
|
+
result = PaletteResult(success=False, source=source)
|
|
908
|
+
|
|
909
|
+
if not source:
|
|
910
|
+
result.error = "Empty source"
|
|
911
|
+
return result
|
|
912
|
+
|
|
913
|
+
# ---- Fetch HTML ----
|
|
914
|
+
try:
|
|
915
|
+
if _is_remote(source):
|
|
916
|
+
html = _fetch_url(source)
|
|
917
|
+
base_url: Optional[str] = source
|
|
918
|
+
else:
|
|
919
|
+
html = _read_local(source)
|
|
920
|
+
base_url = None
|
|
921
|
+
except PaletteExtractionError as exc:
|
|
922
|
+
result.error = str(exc)
|
|
923
|
+
return result
|
|
924
|
+
|
|
925
|
+
if not html or len(html) < 50:
|
|
926
|
+
result.error = "Fetched content is too small to be a valid page"
|
|
927
|
+
return result
|
|
928
|
+
|
|
929
|
+
# ---- Collect CSS ----
|
|
930
|
+
css = _collect_css(html, base_url)
|
|
931
|
+
if not css:
|
|
932
|
+
result.error = "No CSS found (no <style> blocks and no linked stylesheets)"
|
|
933
|
+
return result
|
|
934
|
+
|
|
935
|
+
# ---- Extract ----
|
|
936
|
+
try:
|
|
937
|
+
color_counts = _extract_color_occurrences(css)
|
|
938
|
+
custom_props = _extract_custom_properties(css)
|
|
939
|
+
fonts = _extract_fonts_by_context(css, custom_props=custom_props)
|
|
940
|
+
except Exception as exc:
|
|
941
|
+
logger.exception("Extraction failed")
|
|
942
|
+
result.error = f"Extraction error: {exc}"
|
|
943
|
+
return result
|
|
944
|
+
|
|
945
|
+
if not color_counts:
|
|
946
|
+
result.error = "No colors detected in CSS"
|
|
947
|
+
return result
|
|
948
|
+
|
|
949
|
+
# ---- Build palette ----
|
|
950
|
+
palette, is_dark = build_palette(color_counts, fonts, custom_props)
|
|
951
|
+
|
|
952
|
+
palette_name = name or _slug_from_source(source)
|
|
953
|
+
css_content = _render_palette_css(palette, palette_name, source, is_dark)
|
|
954
|
+
|
|
955
|
+
result.palette_id = palette_name
|
|
956
|
+
result.css_content = css_content
|
|
957
|
+
result.colors_detected = len(color_counts)
|
|
958
|
+
result.fonts_detected = sum(1 for f in fonts if f)
|
|
959
|
+
result.is_dark_mode = is_dark
|
|
960
|
+
|
|
961
|
+
# ---- Write file ----
|
|
962
|
+
if not dry_run:
|
|
963
|
+
try:
|
|
964
|
+
out_dir = Path(output_dir)
|
|
965
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
966
|
+
out_path = out_dir / f"{palette_name}.css"
|
|
967
|
+
out_path.write_text(css_content, encoding="utf-8")
|
|
968
|
+
result.output_path = str(out_path)
|
|
969
|
+
except OSError as exc:
|
|
970
|
+
result.error = f"Failed to write palette file: {exc}"
|
|
971
|
+
return result
|
|
972
|
+
|
|
973
|
+
result.success = True
|
|
974
|
+
logger.info(
|
|
975
|
+
"Extracted palette %r from %s in %.2fs (%d colors, %d fonts, %s mode)",
|
|
976
|
+
palette_name, source, time.time() - t0,
|
|
977
|
+
result.colors_detected, result.fonts_detected,
|
|
978
|
+
"dark" if is_dark else "light",
|
|
979
|
+
)
|
|
980
|
+
return result
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
# ---------------------------------------------------------------------------
|
|
984
|
+
# CLI
|
|
985
|
+
# ---------------------------------------------------------------------------
|
|
986
|
+
def _cli() -> int:
|
|
987
|
+
import argparse
|
|
988
|
+
|
|
989
|
+
parser = argparse.ArgumentParser(
|
|
990
|
+
description="Extract a DifferiX palette from a URL or local HTML file.",
|
|
991
|
+
)
|
|
992
|
+
parser.add_argument("source", help="URL or path to an HTML file")
|
|
993
|
+
parser.add_argument(
|
|
994
|
+
"-o", "--output-dir", default="palettes",
|
|
995
|
+
help="Directory to write the palette file (default: palettes/)",
|
|
996
|
+
)
|
|
997
|
+
parser.add_argument(
|
|
998
|
+
"--name", default=None,
|
|
999
|
+
help="Palette slug (default: derived from the source domain)",
|
|
1000
|
+
)
|
|
1001
|
+
parser.add_argument(
|
|
1002
|
+
"--dry-run", action="store_true",
|
|
1003
|
+
help="Print the palette to stdout without writing a file",
|
|
1004
|
+
)
|
|
1005
|
+
parser.add_argument(
|
|
1006
|
+
"--verbose", "-v", action="store_true",
|
|
1007
|
+
help="Enable debug logging",
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
args = parser.parse_args()
|
|
1011
|
+
|
|
1012
|
+
logging.basicConfig(
|
|
1013
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
1014
|
+
format="%(levelname)s %(message)s",
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
result = extract_palette(
|
|
1018
|
+
source=args.source,
|
|
1019
|
+
output_dir=args.output_dir,
|
|
1020
|
+
name=args.name,
|
|
1021
|
+
dry_run=args.dry_run,
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
if not result.success:
|
|
1025
|
+
print(f"ERROR: {result.error}", file=sys.stderr)
|
|
1026
|
+
return 1
|
|
1027
|
+
|
|
1028
|
+
print(f"Palette: {result.palette_id}")
|
|
1029
|
+
print(f"Colors detected: {result.colors_detected}")
|
|
1030
|
+
print(f"Fonts detected: {result.fonts_detected}")
|
|
1031
|
+
print(f"Mode: {'dark' if result.is_dark_mode else 'light'}")
|
|
1032
|
+
|
|
1033
|
+
if args.dry_run:
|
|
1034
|
+
print()
|
|
1035
|
+
print(result.css_content)
|
|
1036
|
+
else:
|
|
1037
|
+
print(f"Written to: {result.output_path}")
|
|
1038
|
+
|
|
1039
|
+
return 0
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
if __name__ == "__main__":
|
|
1043
|
+
sys.exit(_cli())
|