@pinkpixel/sugarstitch 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/LICENSE +21 -0
- package/OVERVIEW.md +306 -0
- package/README.md +462 -0
- package/assets/banner_dark.png +0 -0
- package/assets/banner_light.png +0 -0
- package/assets/logo.png +0 -0
- package/assets/screenshot_cli.png +0 -0
- package/assets/screenshot_completed.png +0 -0
- package/assets/screenshot_homepage.png +0 -0
- package/assets/screenshot_scraping.png +0 -0
- package/dist/index.js +216 -0
- package/dist/scraper.js +719 -0
- package/dist/server.js +1272 -0
- package/package.json +26 -0
- package/public/favicon.png +0 -0
- package/scripts/add-shebang.js +11 -0
- package/src/index.ts +217 -0
- package/src/scraper.ts +903 -0
- package/src/server.ts +1319 -0
- package/tsconfig.json +12 -0
- package/website/astro.config.mjs +5 -0
- package/website/package-lock.json +6358 -0
- package/website/package.json +18 -0
- package/website/public/banner_dark.png +0 -0
- package/website/public/banner_light.png +0 -0
- package/website/public/favicon.png +0 -0
- package/website/public/screenshot_cli.png +0 -0
- package/website/public/screenshot_completed.png +0 -0
- package/website/public/screenshot_homepage.png +0 -0
- package/website/public/screenshot_scraping.png +0 -0
- package/website/src/layouts/DocsLayout.astro +142 -0
- package/website/src/pages/docs/install.astro +96 -0
- package/website/src/pages/docs/use-the-app.astro +131 -0
- package/website/src/pages/index.astro +94 -0
- package/website/src/styles/site.css +611 -0
- package/website/tsconfig.json +3 -0
- package/website/wrangler.toml +6 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,1319 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import * as fs from 'fs/promises';
|
|
4
|
+
import * as http from 'http';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_PROFILES_FILE,
|
|
8
|
+
dedupeStrings,
|
|
9
|
+
normalizeUrl,
|
|
10
|
+
scrapeUrls,
|
|
11
|
+
previewPattern,
|
|
12
|
+
getSelectorPresets,
|
|
13
|
+
isSelectorPresetId,
|
|
14
|
+
loadSiteProfiles,
|
|
15
|
+
sanitizeSelectorOverrides,
|
|
16
|
+
type SelectorPresetId
|
|
17
|
+
} from './scraper';
|
|
18
|
+
|
|
19
|
+
const PORT = 4177;
|
|
20
|
+
const ROOT_DIRECTORY = process.cwd();
|
|
21
|
+
const ASSETS_DIRECTORY = path.resolve(ROOT_DIRECTORY, 'assets');
|
|
22
|
+
const PUBLIC_DIRECTORY = path.resolve(ROOT_DIRECTORY, 'public');
|
|
23
|
+
|
|
24
|
+
function getContentType(filePath: string): string {
|
|
25
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
26
|
+
|
|
27
|
+
switch (ext) {
|
|
28
|
+
case '.png':
|
|
29
|
+
return 'image/png';
|
|
30
|
+
case '.jpg':
|
|
31
|
+
case '.jpeg':
|
|
32
|
+
return 'image/jpeg';
|
|
33
|
+
case '.gif':
|
|
34
|
+
return 'image/gif';
|
|
35
|
+
case '.svg':
|
|
36
|
+
return 'image/svg+xml';
|
|
37
|
+
case '.webp':
|
|
38
|
+
return 'image/webp';
|
|
39
|
+
case '.ico':
|
|
40
|
+
return 'image/x-icon';
|
|
41
|
+
default:
|
|
42
|
+
return 'application/octet-stream';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function escapeHtml(value: string): string {
|
|
47
|
+
return value
|
|
48
|
+
.replace(/&/g, '&')
|
|
49
|
+
.replace(/</g, '<')
|
|
50
|
+
.replace(/>/g, '>')
|
|
51
|
+
.replace(/"/g, '"')
|
|
52
|
+
.replace(/'/g, ''');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseBody(body: string): URLSearchParams {
|
|
56
|
+
return new URLSearchParams(body);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function pageTemplate(content: string): string {
|
|
60
|
+
return `<!doctype html>
|
|
61
|
+
<html lang="en">
|
|
62
|
+
<head>
|
|
63
|
+
<meta charset="utf-8" />
|
|
64
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
65
|
+
<title>SugarStitch UI</title>
|
|
66
|
+
<link rel="icon" type="image/png" href="/favicon.png" />
|
|
67
|
+
<style>
|
|
68
|
+
:root {
|
|
69
|
+
--bg: #fff8f1;
|
|
70
|
+
--panel: rgba(255, 255, 255, 0.82);
|
|
71
|
+
--panel-strong: #fffdf9;
|
|
72
|
+
--text: #2f1f1b;
|
|
73
|
+
--muted: #7a5f59;
|
|
74
|
+
--line: rgba(107, 63, 46, 0.14);
|
|
75
|
+
--accent: #e8684a;
|
|
76
|
+
--accent-2: #ffb36b;
|
|
77
|
+
--shadow: 0 22px 60px rgba(110, 66, 44, 0.14);
|
|
78
|
+
--radius: 22px;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
html[data-theme="dark"] {
|
|
82
|
+
--bg: #1a1418;
|
|
83
|
+
--panel: rgba(34, 27, 33, 0.84);
|
|
84
|
+
--panel-strong: #231c21;
|
|
85
|
+
--text: #f6e9e2;
|
|
86
|
+
--muted: #d0b7ae;
|
|
87
|
+
--line: rgba(255, 214, 196, 0.12);
|
|
88
|
+
--accent: #ff8f73;
|
|
89
|
+
--accent-2: #ffc074;
|
|
90
|
+
--shadow: 0 24px 64px rgba(0, 0, 0, 0.38);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
* { box-sizing: border-box; }
|
|
94
|
+
|
|
95
|
+
body {
|
|
96
|
+
margin: 0;
|
|
97
|
+
min-height: 100vh;
|
|
98
|
+
font-family: Georgia, "Times New Roman", serif;
|
|
99
|
+
color: var(--text);
|
|
100
|
+
background:
|
|
101
|
+
radial-gradient(circle at top left, rgba(255, 193, 132, 0.7), transparent 30%),
|
|
102
|
+
radial-gradient(circle at top right, rgba(255, 132, 143, 0.32), transparent 28%),
|
|
103
|
+
linear-gradient(180deg, #fff5ea 0%, #fffdfa 50%, #fff7f1 100%);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
html[data-theme="dark"] body {
|
|
107
|
+
background:
|
|
108
|
+
radial-gradient(circle at top left, rgba(255, 143, 115, 0.14), transparent 30%),
|
|
109
|
+
radial-gradient(circle at top right, rgba(126, 203, 255, 0.12), transparent 26%),
|
|
110
|
+
linear-gradient(180deg, #151015 0%, #1a1418 48%, #110d11 100%);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.shell {
|
|
114
|
+
width: min(1080px, calc(100% - 32px));
|
|
115
|
+
margin: 32px auto;
|
|
116
|
+
display: grid;
|
|
117
|
+
gap: 20px;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.shell-top {
|
|
121
|
+
display: flex;
|
|
122
|
+
justify-content: flex-end;
|
|
123
|
+
align-items: center;
|
|
124
|
+
gap: 12px;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.top-actions {
|
|
128
|
+
display: flex;
|
|
129
|
+
align-items: center;
|
|
130
|
+
gap: 12px;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.theme-toggle,
|
|
134
|
+
.icon-link {
|
|
135
|
+
display: inline-flex;
|
|
136
|
+
align-items: center;
|
|
137
|
+
gap: 10px;
|
|
138
|
+
padding: 10px 14px;
|
|
139
|
+
border-radius: 999px;
|
|
140
|
+
border: 1px solid var(--line);
|
|
141
|
+
background: var(--panel);
|
|
142
|
+
color: var(--text);
|
|
143
|
+
backdrop-filter: blur(10px);
|
|
144
|
+
box-shadow: var(--shadow);
|
|
145
|
+
cursor: pointer;
|
|
146
|
+
transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.theme-toggle:hover,
|
|
150
|
+
.icon-link:hover {
|
|
151
|
+
transform: translateY(-1px);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.theme-toggle-label {
|
|
155
|
+
font-size: 0.92rem;
|
|
156
|
+
color: var(--muted);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.theme-icon,
|
|
160
|
+
.icon-link-icon {
|
|
161
|
+
width: 18px;
|
|
162
|
+
height: 18px;
|
|
163
|
+
display: inline-flex;
|
|
164
|
+
align-items: center;
|
|
165
|
+
justify-content: center;
|
|
166
|
+
color: var(--accent);
|
|
167
|
+
flex: 0 0 auto;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.theme-icon svg,
|
|
171
|
+
.icon-link-icon svg {
|
|
172
|
+
width: 18px;
|
|
173
|
+
height: 18px;
|
|
174
|
+
stroke: currentColor;
|
|
175
|
+
fill: none;
|
|
176
|
+
stroke-width: 1.8;
|
|
177
|
+
stroke-linecap: round;
|
|
178
|
+
stroke-linejoin: round;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.icon-link {
|
|
182
|
+
padding: 10px 12px;
|
|
183
|
+
text-decoration: none;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.hero,
|
|
187
|
+
.panel {
|
|
188
|
+
background: var(--panel);
|
|
189
|
+
backdrop-filter: blur(10px);
|
|
190
|
+
border: 1px solid var(--line);
|
|
191
|
+
border-radius: var(--radius);
|
|
192
|
+
box-shadow: var(--shadow);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.hero {
|
|
196
|
+
padding: 28px;
|
|
197
|
+
position: relative;
|
|
198
|
+
overflow: hidden;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.hero::after {
|
|
202
|
+
content: "";
|
|
203
|
+
position: absolute;
|
|
204
|
+
inset: auto -40px -55px auto;
|
|
205
|
+
width: 180px;
|
|
206
|
+
height: 180px;
|
|
207
|
+
border-radius: 999px;
|
|
208
|
+
background: linear-gradient(135deg, rgba(255, 179, 107, 0.35), rgba(232, 104, 74, 0.2));
|
|
209
|
+
filter: blur(6px);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
h1 {
|
|
213
|
+
margin: 0 0 8px;
|
|
214
|
+
font-size: clamp(2rem, 4vw, 3.3rem);
|
|
215
|
+
line-height: 0.95;
|
|
216
|
+
letter-spacing: -0.03em;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.kicker {
|
|
220
|
+
display: inline-block;
|
|
221
|
+
margin-bottom: 10px;
|
|
222
|
+
padding: 6px 10px;
|
|
223
|
+
border-radius: 999px;
|
|
224
|
+
font-size: 0.78rem;
|
|
225
|
+
letter-spacing: 0.08em;
|
|
226
|
+
text-transform: uppercase;
|
|
227
|
+
color: #8c4a33;
|
|
228
|
+
background: rgba(255,255,255,0.65);
|
|
229
|
+
border: 1px solid rgba(140, 74, 51, 0.12);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
html[data-theme="dark"] .kicker {
|
|
233
|
+
color: #ffd3c2;
|
|
234
|
+
background: rgba(255, 255, 255, 0.06);
|
|
235
|
+
border-color: rgba(255, 211, 194, 0.12);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.sub {
|
|
239
|
+
max-width: 720px;
|
|
240
|
+
margin: 0;
|
|
241
|
+
color: var(--muted);
|
|
242
|
+
font-size: 1.05rem;
|
|
243
|
+
line-height: 1.55;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.hero-banner {
|
|
247
|
+
position: relative;
|
|
248
|
+
z-index: 1;
|
|
249
|
+
width: min(100%, 760px);
|
|
250
|
+
margin-top: 8px;
|
|
251
|
+
border-radius: 20px;
|
|
252
|
+
overflow: hidden;
|
|
253
|
+
border: 1px solid var(--line);
|
|
254
|
+
box-shadow: 0 18px 42px rgba(110, 66, 44, 0.16);
|
|
255
|
+
background: rgba(255, 255, 255, 0.54);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.hero-banner img {
|
|
259
|
+
display: block;
|
|
260
|
+
width: 100%;
|
|
261
|
+
height: auto;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.hero-banner .hero-banner-dark {
|
|
265
|
+
display: none;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
html[data-theme="dark"] .hero-banner {
|
|
269
|
+
background: rgba(255, 255, 255, 0.04);
|
|
270
|
+
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.32);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
html[data-theme="dark"] .hero-banner .hero-banner-light {
|
|
274
|
+
display: none;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
html[data-theme="dark"] .hero-banner .hero-banner-dark {
|
|
278
|
+
display: block;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.grid {
|
|
282
|
+
display: grid;
|
|
283
|
+
grid-template-columns: 1.2fr 1fr;
|
|
284
|
+
gap: 20px;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.panel {
|
|
288
|
+
padding: 22px;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
h2 {
|
|
292
|
+
margin: 0 0 14px;
|
|
293
|
+
font-size: 1.25rem;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
form {
|
|
297
|
+
display: grid;
|
|
298
|
+
gap: 14px;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
label {
|
|
302
|
+
display: grid;
|
|
303
|
+
gap: 8px;
|
|
304
|
+
font-size: 0.95rem;
|
|
305
|
+
color: var(--muted);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
input, select, textarea, button {
|
|
309
|
+
font: inherit;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
input, select, textarea {
|
|
313
|
+
width: 100%;
|
|
314
|
+
border: 1px solid rgba(122, 95, 89, 0.22);
|
|
315
|
+
border-radius: 14px;
|
|
316
|
+
padding: 12px 14px;
|
|
317
|
+
background: var(--panel-strong);
|
|
318
|
+
color: var(--text);
|
|
319
|
+
outline: none;
|
|
320
|
+
transition: border-color 140ms ease, transform 140ms ease, box-shadow 140ms ease;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
input:focus, select:focus, textarea:focus {
|
|
324
|
+
border-color: rgba(232, 104, 74, 0.5);
|
|
325
|
+
box-shadow: 0 0 0 4px rgba(232, 104, 74, 0.12);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
textarea {
|
|
329
|
+
min-height: 180px;
|
|
330
|
+
resize: vertical;
|
|
331
|
+
line-height: 1.45;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.row {
|
|
335
|
+
display: grid;
|
|
336
|
+
grid-template-columns: 1fr 1fr;
|
|
337
|
+
gap: 14px;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
.hint, .note {
|
|
341
|
+
margin: 0;
|
|
342
|
+
font-size: 0.9rem;
|
|
343
|
+
color: var(--muted);
|
|
344
|
+
line-height: 1.5;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
.button {
|
|
348
|
+
appearance: none;
|
|
349
|
+
border: 0;
|
|
350
|
+
border-radius: 999px;
|
|
351
|
+
padding: 13px 18px;
|
|
352
|
+
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
|
353
|
+
color: white;
|
|
354
|
+
font-weight: 700;
|
|
355
|
+
cursor: pointer;
|
|
356
|
+
box-shadow: 0 12px 28px rgba(232, 104, 74, 0.28);
|
|
357
|
+
transition: transform 140ms ease, box-shadow 140ms ease;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
.button:hover {
|
|
361
|
+
transform: translateY(-1px);
|
|
362
|
+
box-shadow: 0 16px 34px rgba(232, 104, 74, 0.32);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
.button[disabled] {
|
|
366
|
+
opacity: 0.72;
|
|
367
|
+
cursor: progress;
|
|
368
|
+
transform: none;
|
|
369
|
+
box-shadow: 0 8px 20px rgba(232, 104, 74, 0.18);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
.stats {
|
|
373
|
+
display: grid;
|
|
374
|
+
grid-template-columns: repeat(3, 1fr);
|
|
375
|
+
gap: 12px;
|
|
376
|
+
margin-bottom: 16px;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
.stat {
|
|
380
|
+
padding: 14px;
|
|
381
|
+
border-radius: 18px;
|
|
382
|
+
background: rgba(255, 255, 255, 0.72);
|
|
383
|
+
border: 1px solid var(--line);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
.stat strong {
|
|
387
|
+
display: block;
|
|
388
|
+
font-size: 1.5rem;
|
|
389
|
+
margin-bottom: 3px;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
.log, .list {
|
|
393
|
+
margin: 0;
|
|
394
|
+
padding: 14px;
|
|
395
|
+
border-radius: 18px;
|
|
396
|
+
background: #2c211e;
|
|
397
|
+
color: #ffeadd;
|
|
398
|
+
font-family: "SFMono-Regular", Consolas, monospace;
|
|
399
|
+
font-size: 0.88rem;
|
|
400
|
+
line-height: 1.5;
|
|
401
|
+
white-space: pre-wrap;
|
|
402
|
+
overflow-wrap: anywhere;
|
|
403
|
+
max-height: 360px;
|
|
404
|
+
overflow: auto;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
.list {
|
|
408
|
+
background: rgba(255,255,255,0.72);
|
|
409
|
+
color: var(--text);
|
|
410
|
+
font-family: inherit;
|
|
411
|
+
white-space: normal;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
html[data-theme="dark"] .list,
|
|
415
|
+
html[data-theme="dark"] .stat,
|
|
416
|
+
html[data-theme="dark"] details {
|
|
417
|
+
background: rgba(255, 255, 255, 0.04);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
.list ul {
|
|
421
|
+
margin: 0;
|
|
422
|
+
padding-left: 18px;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
details {
|
|
426
|
+
border: 1px dashed rgba(122, 95, 89, 0.28);
|
|
427
|
+
border-radius: 18px;
|
|
428
|
+
padding: 14px;
|
|
429
|
+
background: rgba(255,255,255,0.45);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
summary {
|
|
433
|
+
cursor: pointer;
|
|
434
|
+
font-weight: 700;
|
|
435
|
+
color: #8c4a33;
|
|
436
|
+
list-style: none;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
summary::-webkit-details-marker {
|
|
440
|
+
display: none;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
.details-grid {
|
|
444
|
+
display: grid;
|
|
445
|
+
gap: 12px;
|
|
446
|
+
margin-top: 14px;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
.status {
|
|
450
|
+
display: inline-block;
|
|
451
|
+
margin-bottom: 12px;
|
|
452
|
+
padding: 8px 12px;
|
|
453
|
+
border-radius: 999px;
|
|
454
|
+
font-size: 0.82rem;
|
|
455
|
+
font-weight: 700;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
.status.ok {
|
|
459
|
+
background: rgba(90, 160, 89, 0.12);
|
|
460
|
+
color: #326632;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
.status.error {
|
|
464
|
+
background: rgba(190, 76, 58, 0.12);
|
|
465
|
+
color: #8d2c1f;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
.overlay {
|
|
469
|
+
position: fixed;
|
|
470
|
+
inset: 0;
|
|
471
|
+
display: none;
|
|
472
|
+
align-items: center;
|
|
473
|
+
justify-content: center;
|
|
474
|
+
padding: 24px;
|
|
475
|
+
background: rgba(255, 248, 241, 0.78);
|
|
476
|
+
backdrop-filter: blur(8px);
|
|
477
|
+
z-index: 999;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
html[data-theme="dark"] .overlay {
|
|
481
|
+
background: rgba(17, 13, 17, 0.72);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
.overlay.active {
|
|
485
|
+
display: flex;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
.overlay-card {
|
|
489
|
+
width: min(520px, 100%);
|
|
490
|
+
padding: 24px;
|
|
491
|
+
border-radius: 24px;
|
|
492
|
+
background: rgba(255, 255, 255, 0.92);
|
|
493
|
+
border: 1px solid var(--line);
|
|
494
|
+
box-shadow: var(--shadow);
|
|
495
|
+
text-align: center;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
html[data-theme="dark"] .log {
|
|
499
|
+
background: #120e12;
|
|
500
|
+
color: #ffe8dc;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
.spinner {
|
|
504
|
+
width: 54px;
|
|
505
|
+
height: 54px;
|
|
506
|
+
margin: 0 auto 16px;
|
|
507
|
+
border-radius: 999px;
|
|
508
|
+
border: 5px solid rgba(232, 104, 74, 0.18);
|
|
509
|
+
border-top-color: var(--accent);
|
|
510
|
+
animation: spin 0.9s linear infinite;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
.progress-track {
|
|
514
|
+
width: 100%;
|
|
515
|
+
height: 10px;
|
|
516
|
+
margin-top: 16px;
|
|
517
|
+
overflow: hidden;
|
|
518
|
+
border-radius: 999px;
|
|
519
|
+
background: rgba(232, 104, 74, 0.12);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
.progress-bar {
|
|
523
|
+
width: 38%;
|
|
524
|
+
height: 100%;
|
|
525
|
+
border-radius: 999px;
|
|
526
|
+
background: linear-gradient(90deg, var(--accent), var(--accent-2));
|
|
527
|
+
animation: glide 1.25s ease-in-out infinite;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
@keyframes spin {
|
|
531
|
+
to { transform: rotate(360deg); }
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
@keyframes glide {
|
|
535
|
+
0% { transform: translateX(-120%); }
|
|
536
|
+
50% { transform: translateX(140%); }
|
|
537
|
+
100% { transform: translateX(280%); }
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
@media (max-width: 860px) {
|
|
541
|
+
.grid, .row, .stats {
|
|
542
|
+
grid-template-columns: 1fr;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
.shell-top {
|
|
546
|
+
justify-content: space-between;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
</style>
|
|
550
|
+
</head>
|
|
551
|
+
<body>
|
|
552
|
+
<div id="workingOverlay" class="overlay" aria-hidden="true">
|
|
553
|
+
<div class="overlay-card">
|
|
554
|
+
<div class="spinner"></div>
|
|
555
|
+
<h2 id="overlayTitle">Working on it...</h2>
|
|
556
|
+
<p id="overlayMessage" class="note">SugarStitch is running now. This page will update when the scrape finishes.</p>
|
|
557
|
+
<div class="progress-track" aria-hidden="true">
|
|
558
|
+
<div class="progress-bar"></div>
|
|
559
|
+
</div>
|
|
560
|
+
</div>
|
|
561
|
+
</div>
|
|
562
|
+
<main class="shell">
|
|
563
|
+
<div class="shell-top">
|
|
564
|
+
<div class="top-actions">
|
|
565
|
+
<a
|
|
566
|
+
class="icon-link"
|
|
567
|
+
href="https://sugarstitch.pinkpixel.dev"
|
|
568
|
+
target="_blank"
|
|
569
|
+
rel="noreferrer"
|
|
570
|
+
aria-label="Open SugarStitch docs"
|
|
571
|
+
title="Open SugarStitch docs"
|
|
572
|
+
>
|
|
573
|
+
<span class="icon-link-icon" aria-hidden="true">
|
|
574
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
575
|
+
<path d="M6 4.75h8.5a2.75 2.75 0 0 1 2.75 2.75V19.25H8.75A2.75 2.75 0 0 0 6 22"></path>
|
|
576
|
+
<path d="M6 4.75A2.75 2.75 0 0 0 3.25 7.5v11.75H12"></path>
|
|
577
|
+
<path d="M8.5 8.5h6"></path>
|
|
578
|
+
<path d="M8.5 11.5h6"></path>
|
|
579
|
+
</svg>
|
|
580
|
+
</span>
|
|
581
|
+
</a>
|
|
582
|
+
<button id="themeToggle" class="theme-toggle" type="button" aria-label="Toggle dark mode" aria-pressed="false">
|
|
583
|
+
<span id="themeIcon" class="theme-icon" aria-hidden="true"></span>
|
|
584
|
+
<span id="themeLabel" class="theme-toggle-label">Dark mode</span>
|
|
585
|
+
</button>
|
|
586
|
+
</div>
|
|
587
|
+
</div>
|
|
588
|
+
${content}
|
|
589
|
+
</main>
|
|
590
|
+
<script>
|
|
591
|
+
(() => {
|
|
592
|
+
const root = document.documentElement;
|
|
593
|
+
const themeToggle = document.getElementById('themeToggle');
|
|
594
|
+
const themeIcon = document.getElementById('themeIcon');
|
|
595
|
+
const themeLabel = document.getElementById('themeLabel');
|
|
596
|
+
const forms = document.querySelectorAll('form[data-enhanced="true"]');
|
|
597
|
+
const overlay = document.getElementById('workingOverlay');
|
|
598
|
+
const overlayTitle = document.getElementById('overlayTitle');
|
|
599
|
+
const overlayMessage = document.getElementById('overlayMessage');
|
|
600
|
+
const darkIcon = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 15.5A8.5 8.5 0 1 1 12.5 4a6.5 6.5 0 0 0 7.5 11.5Z"></path></svg>';
|
|
601
|
+
const lightIcon = '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v2"></path><path d="M12 20v2"></path><path d="M4.93 4.93l1.41 1.41"></path><path d="M17.66 17.66l1.41 1.41"></path><path d="M2 12h2"></path><path d="M20 12h2"></path><path d="M6.34 17.66l-1.41 1.41"></path><path d="M19.07 4.93l-1.41 1.41"></path></svg>';
|
|
602
|
+
|
|
603
|
+
const getPreferredTheme = () => {
|
|
604
|
+
const savedTheme = window.localStorage.getItem('sugarstitch-theme');
|
|
605
|
+
if (savedTheme === 'light' || savedTheme === 'dark') {
|
|
606
|
+
return savedTheme;
|
|
607
|
+
}
|
|
608
|
+
return 'light';
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
const applyTheme = (theme) => {
|
|
612
|
+
root.setAttribute('data-theme', theme);
|
|
613
|
+
if (themeToggle) {
|
|
614
|
+
themeToggle.setAttribute('aria-pressed', theme === 'dark' ? 'true' : 'false');
|
|
615
|
+
}
|
|
616
|
+
if (themeIcon) {
|
|
617
|
+
themeIcon.innerHTML = theme === 'dark' ? lightIcon : darkIcon;
|
|
618
|
+
}
|
|
619
|
+
if (themeLabel) {
|
|
620
|
+
themeLabel.textContent = theme === 'dark' ? 'Light mode' : 'Dark mode';
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
applyTheme(getPreferredTheme());
|
|
625
|
+
|
|
626
|
+
if (themeToggle) {
|
|
627
|
+
themeToggle.addEventListener('click', () => {
|
|
628
|
+
const nextTheme = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
|
|
629
|
+
window.localStorage.setItem('sugarstitch-theme', nextTheme);
|
|
630
|
+
applyTheme(nextTheme);
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
forms.forEach((form) => {
|
|
635
|
+
form.addEventListener('submit', (event) => {
|
|
636
|
+
const submitter = event.submitter;
|
|
637
|
+
const isPreview = submitter && submitter.getAttribute('formaction') === '/preview';
|
|
638
|
+
if (overlay) {
|
|
639
|
+
overlay.classList.add('active');
|
|
640
|
+
overlay.setAttribute('aria-hidden', 'false');
|
|
641
|
+
}
|
|
642
|
+
if (overlayTitle) {
|
|
643
|
+
overlayTitle.textContent = isPreview ? 'Testing selectors...' : 'Scraping in progress...';
|
|
644
|
+
}
|
|
645
|
+
if (overlayMessage) {
|
|
646
|
+
overlayMessage.textContent = isPreview
|
|
647
|
+
? 'SugarStitch is checking what it can pull from the page before saving anything.'
|
|
648
|
+
: 'SugarStitch is fetching pages and downloading matching files. This screen will update when the run finishes.';
|
|
649
|
+
}
|
|
650
|
+
const buttons = form.querySelectorAll('button');
|
|
651
|
+
buttons.forEach((button) => {
|
|
652
|
+
button.disabled = true;
|
|
653
|
+
});
|
|
654
|
+
if (submitter) {
|
|
655
|
+
submitter.textContent = isPreview ? 'Testing...' : 'Scraping...';
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
});
|
|
659
|
+
})();
|
|
660
|
+
</script>
|
|
661
|
+
</body>
|
|
662
|
+
</html>`;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function resolveOutputPaths(outputName: string, outputDirectory?: string): { outputDirectory: string; outputPath: string } {
|
|
666
|
+
const resolvedOutputDirectory = outputDirectory
|
|
667
|
+
? path.resolve(process.cwd(), outputDirectory)
|
|
668
|
+
: process.cwd();
|
|
669
|
+
const outputPath = path.isAbsolute(outputName)
|
|
670
|
+
? outputName
|
|
671
|
+
: path.resolve(resolvedOutputDirectory, outputName);
|
|
672
|
+
|
|
673
|
+
return {
|
|
674
|
+
outputDirectory: resolvedOutputDirectory,
|
|
675
|
+
outputPath
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function renderHome(values?: Record<string, string>, message?: string): Promise<string> {
|
|
680
|
+
const mode = values?.mode === 'many' ? 'many' : 'single';
|
|
681
|
+
const singleUrl = escapeHtml(values?.singleUrl ?? '');
|
|
682
|
+
const urlsText = escapeHtml(values?.urlsText ?? '');
|
|
683
|
+
const output = escapeHtml(values?.output ?? 'pattern-data.json');
|
|
684
|
+
const outputDirectory = escapeHtml(values?.outputDirectory ?? '');
|
|
685
|
+
const preset = values?.preset && isSelectorPresetId(values.preset) ? values.preset : 'generic';
|
|
686
|
+
const crawlEnabled = values?.crawlEnabled === 'true';
|
|
687
|
+
const crawlDepth = escapeHtml(values?.crawlDepth ?? '2');
|
|
688
|
+
const crawlPattern = escapeHtml(values?.crawlPattern ?? '');
|
|
689
|
+
const crawlMaxUrls = escapeHtml(values?.crawlMaxUrls ?? '100');
|
|
690
|
+
const crawlLanguage = escapeHtml(values?.crawlLanguage ?? '');
|
|
691
|
+
const crawlPaginate = values?.crawlPaginate === 'true';
|
|
692
|
+
const crawlMaxPages = escapeHtml(values?.crawlMaxPages ?? '20');
|
|
693
|
+
const crawlAnyDomain = values?.crawlAnyDomain === 'true';
|
|
694
|
+
const profileId = escapeHtml(values?.profileId ?? '');
|
|
695
|
+
const profilesFile = escapeHtml(values?.profilesFile ?? DEFAULT_PROFILES_FILE);
|
|
696
|
+
const titleSelector = escapeHtml(values?.titleSelector ?? '');
|
|
697
|
+
const descriptionSelector = escapeHtml(values?.descriptionSelector ?? '');
|
|
698
|
+
const materialsSelector = escapeHtml(values?.materialsSelector ?? '');
|
|
699
|
+
const instructionsSelector = escapeHtml(values?.instructionsSelector ?? '');
|
|
700
|
+
const imageSelector = escapeHtml(values?.imageSelector ?? '');
|
|
701
|
+
const messageMarkup = message ? `<p class="note">${escapeHtml(message)}</p>` : '';
|
|
702
|
+
const presetOptions = getSelectorPresets()
|
|
703
|
+
.map(selectorPreset => `<option value="${selectorPreset.id}"${preset === selectorPreset.id ? ' selected' : ''}>${escapeHtml(selectorPreset.label)}</option>`)
|
|
704
|
+
.join('');
|
|
705
|
+
const presetDescriptions = getSelectorPresets()
|
|
706
|
+
.map(selectorPreset => `<li><strong>${escapeHtml(selectorPreset.label)}:</strong> ${escapeHtml(selectorPreset.description)}</li>`)
|
|
707
|
+
.join('');
|
|
708
|
+
const availableProfiles = await loadSiteProfiles(path.resolve(process.cwd(), values?.profilesFile ?? DEFAULT_PROFILES_FILE));
|
|
709
|
+
const profileOptions = ['<option value="">None</option>', ...availableProfiles.map(profile => `<option value="${escapeHtml(profile.id)}"${profileId === profile.id ? ' selected' : ''}>${escapeHtml(profile.label)}</option>`)].join('');
|
|
710
|
+
const profileDescriptions = availableProfiles.length > 0
|
|
711
|
+
? `<div class="list"><ul>${availableProfiles.map(profile => `<li><strong>${escapeHtml(profile.label)}:</strong> ${escapeHtml(profile.description ?? 'Saved profile from your config file.')}</li>`).join('')}</ul></div>`
|
|
712
|
+
: `<p class="note">No custom profiles found yet. Create <code>${escapeHtml(values?.profilesFile ?? DEFAULT_PROFILES_FILE)}</code> to add reusable site presets.</p>`;
|
|
713
|
+
|
|
714
|
+
return pageTemplate(`
|
|
715
|
+
<section class="hero">
|
|
716
|
+
<span class="kicker">Sweet little scraping station</span>
|
|
717
|
+
<div class="hero-banner">
|
|
718
|
+
<img class="hero-banner-light" src="/assets/banner_light.png" alt="SugarStitch banner for light mode" />
|
|
719
|
+
<img class="hero-banner-dark" src="/assets/banner_dark.png" alt="SugarStitch banner for dark mode" />
|
|
720
|
+
</div>
|
|
721
|
+
</section>
|
|
722
|
+
|
|
723
|
+
<section class="grid">
|
|
724
|
+
<section class="panel">
|
|
725
|
+
<h2>Run a Scrape</h2>
|
|
726
|
+
${messageMarkup}
|
|
727
|
+
<form method="post" action="/scrape" data-enhanced="true">
|
|
728
|
+
<label>
|
|
729
|
+
Mode
|
|
730
|
+
<select name="mode">
|
|
731
|
+
<option value="single"${mode === 'single' ? ' selected' : ''}>Single URL</option>
|
|
732
|
+
<option value="many"${mode === 'many' ? ' selected' : ''}>Paste Multiple URLs</option>
|
|
733
|
+
</select>
|
|
734
|
+
</label>
|
|
735
|
+
|
|
736
|
+
<label>
|
|
737
|
+
Single Pattern URL
|
|
738
|
+
<input type="url" name="singleUrl" placeholder="https://example.com/pattern" value="${singleUrl}" />
|
|
739
|
+
</label>
|
|
740
|
+
|
|
741
|
+
<label>
|
|
742
|
+
Multiple URLs
|
|
743
|
+
<textarea name="urlsText" placeholder="Paste one pattern URL per line">${urlsText}</textarea>
|
|
744
|
+
</label>
|
|
745
|
+
|
|
746
|
+
<div class="row">
|
|
747
|
+
<label>
|
|
748
|
+
Saved Site Profile
|
|
749
|
+
<select name="profileId">
|
|
750
|
+
${profileOptions}
|
|
751
|
+
</select>
|
|
752
|
+
</label>
|
|
753
|
+
|
|
754
|
+
<label>
|
|
755
|
+
Output JSON File
|
|
756
|
+
<input type="text" name="output" placeholder="pattern-data.json" value="${output}" />
|
|
757
|
+
</label>
|
|
758
|
+
</div>
|
|
759
|
+
|
|
760
|
+
<label>
|
|
761
|
+
Output Directory
|
|
762
|
+
<input type="text" name="outputDirectory" placeholder="Leave blank to save in the project folder" value="${outputDirectory}" />
|
|
763
|
+
</label>
|
|
764
|
+
<p class="hint">For now this is a folder path field. Regular browser pages cannot safely hand a native local folder path back to the server the way a desktop app can.</p>
|
|
765
|
+
|
|
766
|
+
<details>
|
|
767
|
+
<summary>Discovery crawl</summary>
|
|
768
|
+
<div class="details-grid">
|
|
769
|
+
<p class="hint">Use this when you want SugarStitch to start from a listing page, follow links a couple levels deep, and scrape the discovered pages for PDFs and pattern data.</p>
|
|
770
|
+
<label>
|
|
771
|
+
<select name="crawlEnabled">
|
|
772
|
+
<option value="false"${!crawlEnabled ? ' selected' : ''}>Off</option>
|
|
773
|
+
<option value="true"${crawlEnabled ? ' selected' : ''}>On</option>
|
|
774
|
+
</select>
|
|
775
|
+
</label>
|
|
776
|
+
<div class="row">
|
|
777
|
+
<label>
|
|
778
|
+
Crawl Depth
|
|
779
|
+
<input type="number" min="0" max="5" name="crawlDepth" value="${crawlDepth}" />
|
|
780
|
+
</label>
|
|
781
|
+
<label>
|
|
782
|
+
Max Discovered URLs
|
|
783
|
+
<input type="number" min="1" max="500" name="crawlMaxUrls" value="${crawlMaxUrls}" />
|
|
784
|
+
</label>
|
|
785
|
+
</div>
|
|
786
|
+
<div class="row">
|
|
787
|
+
<label>
|
|
788
|
+
Preferred Language
|
|
789
|
+
<input type="text" name="crawlLanguage" placeholder="english" value="${crawlLanguage}" />
|
|
790
|
+
</label>
|
|
791
|
+
<label>
|
|
792
|
+
Pagination
|
|
793
|
+
<select name="crawlPaginate">
|
|
794
|
+
<option value="false"${!crawlPaginate ? ' selected' : ''}>Off</option>
|
|
795
|
+
<option value="true"${crawlPaginate ? ' selected' : ''}>On</option>
|
|
796
|
+
</select>
|
|
797
|
+
</label>
|
|
798
|
+
</div>
|
|
799
|
+
<label>
|
|
800
|
+
Link Filter
|
|
801
|
+
<input type="text" name="crawlPattern" placeholder="free_pattern|pattern|pillow|quilt" value="${crawlPattern}" />
|
|
802
|
+
</label>
|
|
803
|
+
<div class="row">
|
|
804
|
+
<label>
|
|
805
|
+
Max Listing Pages
|
|
806
|
+
<input type="number" min="1" max="100" name="crawlMaxPages" value="${crawlMaxPages}" />
|
|
807
|
+
</label>
|
|
808
|
+
<label>
|
|
809
|
+
<select name="crawlAnyDomain">
|
|
810
|
+
<option value="false"${!crawlAnyDomain ? ' selected' : ''}>Stay on the same domain</option>
|
|
811
|
+
<option value="true"${crawlAnyDomain ? ' selected' : ''}>Allow other domains too</option>
|
|
812
|
+
</select>
|
|
813
|
+
</label>
|
|
814
|
+
</div>
|
|
815
|
+
</div>
|
|
816
|
+
</details>
|
|
817
|
+
|
|
818
|
+
<div class="row">
|
|
819
|
+
<label>
|
|
820
|
+
Profiles Config File
|
|
821
|
+
<input type="text" name="profilesFile" placeholder="${DEFAULT_PROFILES_FILE}" value="${profilesFile}" />
|
|
822
|
+
</label>
|
|
823
|
+
|
|
824
|
+
<label>
|
|
825
|
+
Selector Preset
|
|
826
|
+
<select name="preset">
|
|
827
|
+
${presetOptions}
|
|
828
|
+
</select>
|
|
829
|
+
</label>
|
|
830
|
+
</div>
|
|
831
|
+
|
|
832
|
+
<p class="hint">Presets change the CSS selectors SugarStitch uses in <code>src/scraper.ts</code>. Start with <strong>Generic / Custom</strong> unless you already know the site matches a WordPress article or WooCommerce product layout.</p>
|
|
833
|
+
<details>
|
|
834
|
+
<summary>Advanced selector overrides</summary>
|
|
835
|
+
<div class="details-grid">
|
|
836
|
+
<p class="hint">Use these only if the preset is close but one or two fields still miss. Any field you fill in will take priority over the preset for this run.</p>
|
|
837
|
+
<label>
|
|
838
|
+
Title Selector
|
|
839
|
+
<input type="text" name="titleSelector" placeholder="h1.entry-title" value="${titleSelector}" />
|
|
840
|
+
</label>
|
|
841
|
+
<label>
|
|
842
|
+
Description Selector
|
|
843
|
+
<input type="text" name="descriptionSelector" placeholder=".entry-content p" value="${descriptionSelector}" />
|
|
844
|
+
</label>
|
|
845
|
+
<label>
|
|
846
|
+
Materials Selector
|
|
847
|
+
<input type="text" name="materialsSelector" placeholder=".materials-list li" value="${materialsSelector}" />
|
|
848
|
+
</label>
|
|
849
|
+
<label>
|
|
850
|
+
Instructions Selector
|
|
851
|
+
<input type="text" name="instructionsSelector" placeholder=".instruction-step" value="${instructionsSelector}" />
|
|
852
|
+
</label>
|
|
853
|
+
<label>
|
|
854
|
+
Image Selector
|
|
855
|
+
<input type="text" name="imageSelector" placeholder=".entry-content img" value="${imageSelector}" />
|
|
856
|
+
</label>
|
|
857
|
+
</div>
|
|
858
|
+
</details>
|
|
859
|
+
<div class="row">
|
|
860
|
+
<button class="button" type="submit" formaction="/preview">Test Selectors</button>
|
|
861
|
+
<button class="button" type="submit" formaction="/scrape">Start Scraping</button>
|
|
862
|
+
</div>
|
|
863
|
+
</form>
|
|
864
|
+
</section>
|
|
865
|
+
|
|
866
|
+
<section class="panel">
|
|
867
|
+
<h2>Saved Profiles</h2>
|
|
868
|
+
${profileDescriptions}
|
|
869
|
+
<h2>Preset Guide</h2>
|
|
870
|
+
<div class="list"><ul>${presetDescriptions}</ul></div>
|
|
871
|
+
<h2>How It Feels</h2>
|
|
872
|
+
<p class="note">Use <strong>Single URL</strong> for one pattern page. Use <strong>Paste Multiple URLs</strong> when you have a batch list and want the UI to behave like the CLI file mode without creating a text file first.</p>
|
|
873
|
+
<p class="note">Downloads land inside your chosen output directory. SugarStitch writes the JSON file there and creates <code>images/</code> and <code>pdfs/</code> folders underneath it.</p>
|
|
874
|
+
<p class="note">Already-known URLs are skipped before the scraper fetches them again, so reruns are a lot less wasteful now.</p>
|
|
875
|
+
</section>
|
|
876
|
+
</section>
|
|
877
|
+
`);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function renderResults(params: {
|
|
881
|
+
status: 'ok' | 'error';
|
|
882
|
+
title: string;
|
|
883
|
+
logs: string[];
|
|
884
|
+
outputPath?: string;
|
|
885
|
+
outputDirectory?: string;
|
|
886
|
+
presetLabel?: string;
|
|
887
|
+
profileLabel?: string;
|
|
888
|
+
selectorOverrides?: Record<string, string>;
|
|
889
|
+
scrapedCount?: number;
|
|
890
|
+
skippedCount?: number;
|
|
891
|
+
failedCount?: number;
|
|
892
|
+
patterns?: string[];
|
|
893
|
+
completionNote?: string;
|
|
894
|
+
discoveredUrlCount?: number;
|
|
895
|
+
}): string {
|
|
896
|
+
const statsMarkup = params.status === 'ok'
|
|
897
|
+
? `<div class="stats">
|
|
898
|
+
<div class="stat"><strong>${params.scrapedCount ?? 0}</strong>New Patterns</div>
|
|
899
|
+
<div class="stat"><strong>${params.skippedCount ?? 0}</strong>Skipped</div>
|
|
900
|
+
<div class="stat"><strong>${params.failedCount ?? 0}</strong>Failed</div>
|
|
901
|
+
</div>`
|
|
902
|
+
: '';
|
|
903
|
+
const patternsMarkup = params.patterns && params.patterns.length > 0
|
|
904
|
+
? `<div class="list"><ul>${params.patterns.map(pattern => `<li>${escapeHtml(pattern)}</li>`).join('')}</ul></div>`
|
|
905
|
+
: '<p class="note">No new pattern titles were written during this run.</p>';
|
|
906
|
+
const overrideItems = params.selectorOverrides
|
|
907
|
+
? Object.entries(params.selectorOverrides)
|
|
908
|
+
.map(([key, value]) => `<li><strong>${escapeHtml(key)}:</strong> <code>${escapeHtml(value)}</code></li>`)
|
|
909
|
+
.join('')
|
|
910
|
+
: '';
|
|
911
|
+
|
|
912
|
+
return pageTemplate(`
|
|
913
|
+
<section class="hero">
|
|
914
|
+
<span class="status ${params.status}">${params.status === 'ok' ? 'Run complete' : 'Run failed'}</span>
|
|
915
|
+
<h1>${escapeHtml(params.title)}</h1>
|
|
916
|
+
<p class="sub">SugarStitch finished this local run. You can review the logs below or head back and try another batch.</p>
|
|
917
|
+
</section>
|
|
918
|
+
|
|
919
|
+
<section class="grid">
|
|
920
|
+
<section class="panel">
|
|
921
|
+
<h2>Summary</h2>
|
|
922
|
+
${statsMarkup}
|
|
923
|
+
${params.outputPath ? `<p class="note">Output file: <code>${escapeHtml(params.outputPath)}</code></p>` : ''}
|
|
924
|
+
${params.outputDirectory ? `<p class="note">Output directory: <code>${escapeHtml(params.outputDirectory)}</code></p>` : ''}
|
|
925
|
+
${typeof params.discoveredUrlCount === 'number' ? `<p class="note">Discovered page URLs processed: <strong>${params.discoveredUrlCount}</strong></p>` : ''}
|
|
926
|
+
${params.presetLabel ? `<p class="note">Selector preset: <strong>${escapeHtml(params.presetLabel)}</strong></p>` : ''}
|
|
927
|
+
${params.profileLabel ? `<p class="note">Saved profile: <strong>${escapeHtml(params.profileLabel)}</strong></p>` : ''}
|
|
928
|
+
${params.completionNote ? `<p class="note">${escapeHtml(params.completionNote)}</p>` : ''}
|
|
929
|
+
${overrideItems ? `<h2>Advanced Overrides</h2><div class="list"><ul>${overrideItems}</ul></div>` : ''}
|
|
930
|
+
<h2>New Pattern Titles</h2>
|
|
931
|
+
${patternsMarkup}
|
|
932
|
+
</section>
|
|
933
|
+
|
|
934
|
+
<section class="panel">
|
|
935
|
+
<h2>Run Log</h2>
|
|
936
|
+
<pre class="log">${escapeHtml(params.logs.join('\n'))}</pre>
|
|
937
|
+
</section>
|
|
938
|
+
</section>
|
|
939
|
+
|
|
940
|
+
<section class="panel">
|
|
941
|
+
<a class="button" href="/">Back to the form</a>
|
|
942
|
+
</section>
|
|
943
|
+
`);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function renderPreview(params: {
|
|
947
|
+
title: string;
|
|
948
|
+
description: string;
|
|
949
|
+
materials: string[];
|
|
950
|
+
instructions: string[];
|
|
951
|
+
imageUrls: string[];
|
|
952
|
+
pdfUrls: string[];
|
|
953
|
+
logs: string[];
|
|
954
|
+
presetLabel: string;
|
|
955
|
+
profileLabel?: string;
|
|
956
|
+
selectorOverrides?: Record<string, string>;
|
|
957
|
+
}): string {
|
|
958
|
+
const overrideItems = params.selectorOverrides
|
|
959
|
+
? Object.entries(params.selectorOverrides)
|
|
960
|
+
.map(([key, value]) => `<li><strong>${escapeHtml(key)}:</strong> <code>${escapeHtml(value)}</code></li>`)
|
|
961
|
+
.join('')
|
|
962
|
+
: '';
|
|
963
|
+
|
|
964
|
+
return pageTemplate(`
|
|
965
|
+
<section class="hero">
|
|
966
|
+
<span class="status ok">Preview ready</span>
|
|
967
|
+
<h1>Selector Test Preview</h1>
|
|
968
|
+
<p class="sub">This is what SugarStitch would pull from the page before any JSON writes or file downloads happen.</p>
|
|
969
|
+
</section>
|
|
970
|
+
|
|
971
|
+
<section class="grid">
|
|
972
|
+
<section class="panel">
|
|
973
|
+
<h2>Preview Summary</h2>
|
|
974
|
+
<p class="note">Selector preset: <strong>${escapeHtml(params.presetLabel)}</strong></p>
|
|
975
|
+
${params.profileLabel ? `<p class="note">Saved profile: <strong>${escapeHtml(params.profileLabel)}</strong></p>` : ''}
|
|
976
|
+
${overrideItems ? `<h2>Advanced Overrides</h2><div class="list"><ul>${overrideItems}</ul></div>` : ''}
|
|
977
|
+
<h2>Title</h2>
|
|
978
|
+
<div class="list"><p>${escapeHtml(params.title)}</p></div>
|
|
979
|
+
<h2>Description</h2>
|
|
980
|
+
<div class="list"><p>${escapeHtml(params.description)}</p></div>
|
|
981
|
+
<h2>Materials</h2>
|
|
982
|
+
<div class="list"><ul>${params.materials.map(item => `<li>${escapeHtml(item)}</li>`).join('') || '<li>No materials matched.</li>'}</ul></div>
|
|
983
|
+
<h2>Instructions</h2>
|
|
984
|
+
<div class="list"><ul>${params.instructions.map(item => `<li>${escapeHtml(item)}</li>`).join('') || '<li>No instructions matched.</li>'}</ul></div>
|
|
985
|
+
</section>
|
|
986
|
+
|
|
987
|
+
<section class="panel">
|
|
988
|
+
<h2>Found Assets</h2>
|
|
989
|
+
<p class="note">Images found: <strong>${params.imageUrls.length}</strong></p>
|
|
990
|
+
<div class="list"><ul>${params.imageUrls.map(item => `<li>${escapeHtml(item)}</li>`).join('') || '<li>No images matched.</li>'}</ul></div>
|
|
991
|
+
<h2>Linked PDFs</h2>
|
|
992
|
+
<div class="list"><ul>${params.pdfUrls.map(item => `<li>${escapeHtml(item)}</li>`).join('') || '<li>No PDFs matched.</li>'}</ul></div>
|
|
993
|
+
<h2>Preview Log</h2>
|
|
994
|
+
<pre class="log">${escapeHtml(params.logs.join('\n'))}</pre>
|
|
995
|
+
</section>
|
|
996
|
+
</section>
|
|
997
|
+
|
|
998
|
+
<section class="panel">
|
|
999
|
+
<a class="button" href="/">Back to the form</a>
|
|
1000
|
+
</section>
|
|
1001
|
+
`);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
async function handleScrape(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
1005
|
+
const bodyChunks: Buffer[] = [];
|
|
1006
|
+
|
|
1007
|
+
for await (const chunk of req) {
|
|
1008
|
+
bodyChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
const params = parseBody(Buffer.concat(bodyChunks).toString('utf8'));
|
|
1012
|
+
const mode = params.get('mode') === 'many' ? 'many' : 'single';
|
|
1013
|
+
const singleUrl = (params.get('singleUrl') ?? '').trim();
|
|
1014
|
+
const urlsText = (params.get('urlsText') ?? '').trim();
|
|
1015
|
+
const outputName = (params.get('output') ?? 'pattern-data.json').trim() || 'pattern-data.json';
|
|
1016
|
+
const outputDirectoryInput = (params.get('outputDirectory') ?? '').trim();
|
|
1017
|
+
const crawlEnabled = (params.get('crawlEnabled') ?? 'false') === 'true';
|
|
1018
|
+
const crawlDepth = (params.get('crawlDepth') ?? '2').trim() || '2';
|
|
1019
|
+
const crawlPattern = (params.get('crawlPattern') ?? '').trim();
|
|
1020
|
+
const crawlMaxUrls = (params.get('crawlMaxUrls') ?? '100').trim() || '100';
|
|
1021
|
+
const crawlLanguage = (params.get('crawlLanguage') ?? '').trim();
|
|
1022
|
+
const crawlPaginate = (params.get('crawlPaginate') ?? 'false') === 'true';
|
|
1023
|
+
const crawlMaxPages = (params.get('crawlMaxPages') ?? '20').trim() || '20';
|
|
1024
|
+
const crawlAnyDomain = (params.get('crawlAnyDomain') ?? 'false') === 'true';
|
|
1025
|
+
const preset = isSelectorPresetId(params.get('preset') ?? '') ? (params.get('preset') as SelectorPresetId) : 'generic';
|
|
1026
|
+
const profilesFile = (params.get('profilesFile') ?? DEFAULT_PROFILES_FILE).trim() || DEFAULT_PROFILES_FILE;
|
|
1027
|
+
const profileId = (params.get('profileId') ?? '').trim();
|
|
1028
|
+
const selectorOverrides = sanitizeSelectorOverrides({
|
|
1029
|
+
titleSelector: params.get('titleSelector') ?? '',
|
|
1030
|
+
descriptionSelector: params.get('descriptionSelector') ?? '',
|
|
1031
|
+
materialsSelector: params.get('materialsSelector') ?? '',
|
|
1032
|
+
instructionsSelector: params.get('instructionsSelector') ?? '',
|
|
1033
|
+
imageSelector: params.get('imageSelector') ?? ''
|
|
1034
|
+
});
|
|
1035
|
+
const values = {
|
|
1036
|
+
mode,
|
|
1037
|
+
singleUrl,
|
|
1038
|
+
urlsText,
|
|
1039
|
+
output: outputName,
|
|
1040
|
+
outputDirectory: outputDirectoryInput,
|
|
1041
|
+
crawlEnabled: String(crawlEnabled),
|
|
1042
|
+
crawlDepth,
|
|
1043
|
+
crawlPattern,
|
|
1044
|
+
crawlMaxUrls,
|
|
1045
|
+
crawlLanguage,
|
|
1046
|
+
crawlPaginate: String(crawlPaginate),
|
|
1047
|
+
crawlMaxPages,
|
|
1048
|
+
crawlAnyDomain: String(crawlAnyDomain),
|
|
1049
|
+
preset,
|
|
1050
|
+
profileId,
|
|
1051
|
+
profilesFile,
|
|
1052
|
+
titleSelector: selectorOverrides?.titleSelector ?? '',
|
|
1053
|
+
descriptionSelector: selectorOverrides?.descriptionSelector ?? '',
|
|
1054
|
+
materialsSelector: selectorOverrides?.materialsSelector ?? '',
|
|
1055
|
+
instructionsSelector: selectorOverrides?.instructionsSelector ?? '',
|
|
1056
|
+
imageSelector: selectorOverrides?.imageSelector ?? ''
|
|
1057
|
+
};
|
|
1058
|
+
|
|
1059
|
+
let urls: string[] = [];
|
|
1060
|
+
|
|
1061
|
+
if (mode === 'single') {
|
|
1062
|
+
const normalizedUrl = normalizeUrl(singleUrl);
|
|
1063
|
+
if (!normalizedUrl) {
|
|
1064
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
1065
|
+
res.end(await renderHome(values, 'Please enter one valid http(s) URL for single mode.'));
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
urls = [normalizedUrl];
|
|
1069
|
+
} else {
|
|
1070
|
+
urls = dedupeStrings(
|
|
1071
|
+
urlsText
|
|
1072
|
+
.split(/\r?\n/)
|
|
1073
|
+
.map(line => line.trim())
|
|
1074
|
+
.filter(line => line.length > 0)
|
|
1075
|
+
.map(normalizeUrl)
|
|
1076
|
+
.filter((url): url is string => Boolean(url))
|
|
1077
|
+
);
|
|
1078
|
+
|
|
1079
|
+
if (urls.length === 0) {
|
|
1080
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
1081
|
+
res.end(await renderHome(values, 'Paste at least one valid http(s) URL in multiple mode.'));
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const logs: string[] = [];
|
|
1087
|
+
|
|
1088
|
+
try {
|
|
1089
|
+
const { outputDirectory, outputPath } = resolveOutputPaths(outputName, outputDirectoryInput);
|
|
1090
|
+
const result = await scrapeUrls({
|
|
1091
|
+
urls,
|
|
1092
|
+
outputPath,
|
|
1093
|
+
preset,
|
|
1094
|
+
profileId: profileId || undefined,
|
|
1095
|
+
profilesPath: path.resolve(process.cwd(), profilesFile),
|
|
1096
|
+
selectorOverrides,
|
|
1097
|
+
crawl: {
|
|
1098
|
+
enabled: crawlEnabled,
|
|
1099
|
+
maxDepth: Number.parseInt(crawlDepth, 10),
|
|
1100
|
+
sameDomainOnly: !crawlAnyDomain,
|
|
1101
|
+
linkPattern: crawlPattern,
|
|
1102
|
+
maxDiscoveredUrls: Number.parseInt(crawlMaxUrls, 10),
|
|
1103
|
+
language: crawlLanguage,
|
|
1104
|
+
paginate: crawlPaginate,
|
|
1105
|
+
maxPaginationPages: Number.parseInt(crawlMaxPages, 10)
|
|
1106
|
+
},
|
|
1107
|
+
workingDirectory: outputDirectory,
|
|
1108
|
+
logger: message => logs.push(message)
|
|
1109
|
+
});
|
|
1110
|
+
const lowMatchCount = result.patterns.filter(pattern =>
|
|
1111
|
+
pattern.description === 'No description found.' &&
|
|
1112
|
+
pattern.materials.length === 0 &&
|
|
1113
|
+
pattern.instructions.length === 0 &&
|
|
1114
|
+
pattern.localImages.length === 0 &&
|
|
1115
|
+
pattern.localPdfs.length > 0
|
|
1116
|
+
).length;
|
|
1117
|
+
const completionNote = lowMatchCount > 0
|
|
1118
|
+
? `${lowMatchCount} saved item(s) mainly matched PDFs and titles. That still counts as a successful scrape, but trying another preset or running Test Selectors may help capture more page content.`
|
|
1119
|
+
: undefined;
|
|
1120
|
+
|
|
1121
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
1122
|
+
res.end(renderResults({
|
|
1123
|
+
status: 'ok',
|
|
1124
|
+
title: 'Scrape completed successfully',
|
|
1125
|
+
logs,
|
|
1126
|
+
outputPath: result.outputPath,
|
|
1127
|
+
outputDirectory: result.outputDirectory,
|
|
1128
|
+
discoveredUrlCount: result.discoveredUrlCount,
|
|
1129
|
+
presetLabel: getSelectorPresets().find(selectorPreset => selectorPreset.id === result.preset)?.label ?? result.preset,
|
|
1130
|
+
profileLabel: result.profileLabel,
|
|
1131
|
+
completionNote,
|
|
1132
|
+
selectorOverrides: result.selectorOverrides ? {
|
|
1133
|
+
title: result.selectorOverrides.titleSelector ?? '',
|
|
1134
|
+
description: result.selectorOverrides.descriptionSelector ?? '',
|
|
1135
|
+
materials: result.selectorOverrides.materialsSelector ?? '',
|
|
1136
|
+
instructions: result.selectorOverrides.instructionsSelector ?? '',
|
|
1137
|
+
images: result.selectorOverrides.imageSelector ?? ''
|
|
1138
|
+
} : undefined,
|
|
1139
|
+
scrapedCount: result.scrapedCount,
|
|
1140
|
+
skippedCount: result.skippedCount,
|
|
1141
|
+
failedCount: result.failedCount,
|
|
1142
|
+
patterns: result.patterns.map(pattern => pattern.title)
|
|
1143
|
+
}));
|
|
1144
|
+
} catch (error: any) {
|
|
1145
|
+
logs.push(`❌ ${error.message}`);
|
|
1146
|
+
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
1147
|
+
res.end(renderResults({
|
|
1148
|
+
status: 'error',
|
|
1149
|
+
title: 'Scrape failed',
|
|
1150
|
+
logs
|
|
1151
|
+
}));
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
async function handlePreview(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
1156
|
+
const bodyChunks: Buffer[] = [];
|
|
1157
|
+
|
|
1158
|
+
for await (const chunk of req) {
|
|
1159
|
+
bodyChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const params = parseBody(Buffer.concat(bodyChunks).toString('utf8'));
|
|
1163
|
+
const singleUrl = (params.get('singleUrl') ?? '').trim();
|
|
1164
|
+
const preset = isSelectorPresetId(params.get('preset') ?? '') ? (params.get('preset') as SelectorPresetId) : 'generic';
|
|
1165
|
+
const profilesFile = (params.get('profilesFile') ?? DEFAULT_PROFILES_FILE).trim() || DEFAULT_PROFILES_FILE;
|
|
1166
|
+
const profileId = (params.get('profileId') ?? '').trim();
|
|
1167
|
+
const selectorOverrides = sanitizeSelectorOverrides({
|
|
1168
|
+
titleSelector: params.get('titleSelector') ?? '',
|
|
1169
|
+
descriptionSelector: params.get('descriptionSelector') ?? '',
|
|
1170
|
+
materialsSelector: params.get('materialsSelector') ?? '',
|
|
1171
|
+
instructionsSelector: params.get('instructionsSelector') ?? '',
|
|
1172
|
+
imageSelector: params.get('imageSelector') ?? ''
|
|
1173
|
+
});
|
|
1174
|
+
const values = {
|
|
1175
|
+
mode: 'single',
|
|
1176
|
+
singleUrl,
|
|
1177
|
+
urlsText: '',
|
|
1178
|
+
output: (params.get('output') ?? 'pattern-data.json').trim() || 'pattern-data.json',
|
|
1179
|
+
outputDirectory: (params.get('outputDirectory') ?? '').trim(),
|
|
1180
|
+
crawlEnabled: String((params.get('crawlEnabled') ?? 'false') === 'true'),
|
|
1181
|
+
crawlDepth: (params.get('crawlDepth') ?? '2').trim() || '2',
|
|
1182
|
+
crawlPattern: (params.get('crawlPattern') ?? '').trim(),
|
|
1183
|
+
crawlMaxUrls: (params.get('crawlMaxUrls') ?? '100').trim() || '100',
|
|
1184
|
+
crawlLanguage: (params.get('crawlLanguage') ?? '').trim(),
|
|
1185
|
+
crawlPaginate: String((params.get('crawlPaginate') ?? 'false') === 'true'),
|
|
1186
|
+
crawlMaxPages: (params.get('crawlMaxPages') ?? '20').trim() || '20',
|
|
1187
|
+
crawlAnyDomain: String((params.get('crawlAnyDomain') ?? 'false') === 'true'),
|
|
1188
|
+
preset,
|
|
1189
|
+
profileId,
|
|
1190
|
+
profilesFile,
|
|
1191
|
+
titleSelector: selectorOverrides?.titleSelector ?? '',
|
|
1192
|
+
descriptionSelector: selectorOverrides?.descriptionSelector ?? '',
|
|
1193
|
+
materialsSelector: selectorOverrides?.materialsSelector ?? '',
|
|
1194
|
+
instructionsSelector: selectorOverrides?.instructionsSelector ?? '',
|
|
1195
|
+
imageSelector: selectorOverrides?.imageSelector ?? ''
|
|
1196
|
+
};
|
|
1197
|
+
|
|
1198
|
+
const normalizedUrl = normalizeUrl(singleUrl);
|
|
1199
|
+
if (!normalizedUrl) {
|
|
1200
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
1201
|
+
res.end(await renderHome(values, 'Enter one valid URL in Single URL mode before testing selectors.'));
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
const logs: string[] = [];
|
|
1206
|
+
|
|
1207
|
+
try {
|
|
1208
|
+
const preview = await previewPattern({
|
|
1209
|
+
url: normalizedUrl,
|
|
1210
|
+
preset,
|
|
1211
|
+
profileId: profileId || undefined,
|
|
1212
|
+
profilesPath: path.resolve(process.cwd(), profilesFile),
|
|
1213
|
+
selectorOverrides
|
|
1214
|
+
}, message => logs.push(message));
|
|
1215
|
+
|
|
1216
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
1217
|
+
res.end(renderPreview({
|
|
1218
|
+
title: preview.title,
|
|
1219
|
+
description: preview.description,
|
|
1220
|
+
materials: preview.materials,
|
|
1221
|
+
instructions: preview.instructions,
|
|
1222
|
+
imageUrls: preview.imageUrls,
|
|
1223
|
+
pdfUrls: preview.pdfUrls,
|
|
1224
|
+
logs,
|
|
1225
|
+
presetLabel: preview.presetLabel,
|
|
1226
|
+
profileLabel: preview.profileLabel,
|
|
1227
|
+
selectorOverrides: preview.selectorOverrides ? {
|
|
1228
|
+
title: preview.selectorOverrides.titleSelector ?? '',
|
|
1229
|
+
description: preview.selectorOverrides.descriptionSelector ?? '',
|
|
1230
|
+
materials: preview.selectorOverrides.materialsSelector ?? '',
|
|
1231
|
+
instructions: preview.selectorOverrides.instructionsSelector ?? '',
|
|
1232
|
+
images: preview.selectorOverrides.imageSelector ?? ''
|
|
1233
|
+
} : undefined
|
|
1234
|
+
}));
|
|
1235
|
+
} catch (error: any) {
|
|
1236
|
+
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
1237
|
+
res.end(await renderHome(values, error.message));
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
async function serveStaticFile(res: http.ServerResponse, filePath: string): Promise<void> {
|
|
1242
|
+
try {
|
|
1243
|
+
const file = await fs.readFile(filePath);
|
|
1244
|
+
res.writeHead(200, { 'Content-Type': getContentType(filePath) });
|
|
1245
|
+
res.end(file);
|
|
1246
|
+
} catch (error: any) {
|
|
1247
|
+
if (error?.code === 'ENOENT') {
|
|
1248
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
1249
|
+
res.end('Not found');
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
1254
|
+
res.end('Unable to load file');
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
const server = http.createServer(async (req, res) => {
|
|
1259
|
+
if (!req.url) {
|
|
1260
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
1261
|
+
res.end('Bad request');
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
if ((req.method === 'GET' || req.method === 'HEAD') && req.url === '/') {
|
|
1266
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
1267
|
+
res.end(req.method === 'HEAD' ? undefined : await renderHome());
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
if ((req.method === 'GET' || req.method === 'HEAD') && req.url === '/favicon.png') {
|
|
1272
|
+
if (req.method === 'HEAD') {
|
|
1273
|
+
res.writeHead(200, { 'Content-Type': 'image/png' });
|
|
1274
|
+
res.end();
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
await serveStaticFile(res, path.resolve(PUBLIC_DIRECTORY, 'favicon.png'));
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
if ((req.method === 'GET' || req.method === 'HEAD') && req.url.startsWith('/assets/')) {
|
|
1283
|
+
const assetPath = req.url.slice('/assets/'.length);
|
|
1284
|
+
const safeAssetPath = path.normalize(assetPath).replace(/^(\.\.(\/|\\|$))+/, '');
|
|
1285
|
+
const filePath = path.resolve(ASSETS_DIRECTORY, safeAssetPath);
|
|
1286
|
+
|
|
1287
|
+
if (!filePath.startsWith(ASSETS_DIRECTORY)) {
|
|
1288
|
+
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
1289
|
+
res.end('Forbidden');
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
if (req.method === 'HEAD') {
|
|
1294
|
+
res.writeHead(200, { 'Content-Type': getContentType(filePath) });
|
|
1295
|
+
res.end();
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
await serveStaticFile(res, filePath);
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
if (req.method === 'POST' && req.url === '/scrape') {
|
|
1304
|
+
await handleScrape(req, res);
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
if (req.method === 'POST' && req.url === '/preview') {
|
|
1309
|
+
await handlePreview(req, res);
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
1314
|
+
res.end('Not found');
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
server.listen(PORT, () => {
|
|
1318
|
+
console.log(`SugarStitch UI is running at http://localhost:${PORT}`);
|
|
1319
|
+
});
|