design-clone 2.1.0 → 3.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/README.md +13 -34
- package/SKILL.md +69 -45
- package/bin/cli.js +22 -4
- package/bin/commands/clone-site.js +31 -171
- package/bin/commands/help.js +19 -6
- package/bin/commands/init.js +9 -86
- package/bin/commands/uninstall.js +105 -0
- package/bin/commands/update.js +70 -0
- package/bin/commands/verify.js +7 -14
- package/bin/utils/paths.js +28 -0
- package/bin/utils/validate.js +2 -22
- package/bin/utils/version.js +23 -0
- package/docs/code-standards.md +789 -0
- package/docs/codebase-summary.md +533 -286
- package/docs/index.md +74 -0
- package/docs/project-overview-pdr.md +797 -0
- package/docs/system-architecture.md +718 -0
- package/package.json +14 -17
- package/src/ai/prompts/design-tokens/basic.md +80 -0
- package/src/ai/prompts/design-tokens/section-with-css.md +41 -0
- package/src/ai/prompts/design-tokens/section.md +48 -0
- package/src/ai/prompts/design-tokens/with-css.md +87 -0
- package/src/ai/prompts/structure-analysis/basic.md +55 -0
- package/src/ai/prompts/structure-analysis/with-context.md +59 -0
- package/src/ai/prompts/structure-analysis/with-dimensions.md +63 -0
- package/src/ai/prompts/structure-analysis/with-hierarchy.md +73 -0
- package/src/ai/prompts/ux-audit/aggregation.md +42 -0
- package/src/ai/prompts/ux-audit/desktop.md +92 -0
- package/src/ai/prompts/ux-audit/mobile.md +93 -0
- package/src/ai/prompts/ux-audit/tablet.md +92 -0
- package/src/core/animation/animation-extractor-ast.js +183 -0
- package/src/core/animation/animation-extractor-output.js +152 -0
- package/src/core/animation/animation-extractor.js +178 -0
- package/src/core/animation/state-capture-detection.js +200 -0
- package/src/core/animation/state-capture.js +193 -0
- package/src/core/capture/browser-context-pool.js +96 -0
- package/src/core/capture/multi-page-screenshot-page.js +110 -0
- package/src/core/capture/multi-page-screenshot.js +208 -0
- package/src/core/capture/screenshot-extraction.js +186 -0
- package/src/core/capture/screenshot-helpers.js +175 -0
- package/src/core/capture/screenshot-orchestrator.js +174 -0
- package/src/core/capture/screenshot-viewport.js +93 -0
- package/src/core/capture/screenshot.js +192 -0
- package/src/core/content/content-counter-dom.js +191 -0
- package/src/core/content/content-counter.js +76 -0
- package/src/core/css/breakpoint-detector.js +66 -0
- package/src/core/css/chromium-defaults.json +23 -0
- package/src/core/css/computed-style-extractor.js +102 -0
- package/src/core/css/css-chunker.js +103 -0
- package/src/core/css/filter-css-dead-code.js +120 -0
- package/src/core/css/filter-css-html-analyzer.js +110 -0
- package/src/core/css/filter-css-selector-matcher.js +172 -0
- package/src/core/css/filter-css.js +206 -0
- package/src/core/css/merge-css-atrule-processor.js +158 -0
- package/src/core/css/merge-css-file-io.js +68 -0
- package/src/core/css/merge-css.js +148 -0
- package/src/core/detection/framework-detector-routing.js +68 -0
- package/src/core/detection/framework-detector-signals.js +65 -0
- package/src/core/detection/framework-detector.js +198 -0
- package/src/core/dimension/dimension-extractor-card-detector.js +82 -0
- package/src/core/dimension/dimension-extractor.js +317 -0
- package/src/core/dimension/dimension-output-ai-summary.js +111 -0
- package/src/core/dimension/dimension-output.js +173 -0
- package/src/core/dimension/dom-tree-analyzer-tree-builders.js +95 -0
- package/src/core/dimension/dom-tree-analyzer.js +191 -0
- package/src/core/discovery/app-state-snapshot-capture.js +195 -0
- package/src/core/discovery/app-state-snapshot-utils.js +178 -0
- package/src/core/discovery/app-state-snapshot.js +131 -0
- package/src/core/discovery/discover-pages-routes.js +84 -0
- package/src/core/discovery/discover-pages-utils.js +177 -0
- package/src/core/discovery/discover-pages.js +191 -0
- package/src/core/html/html-extractor-inline-styler.js +70 -0
- package/src/core/html/html-extractor.js +147 -0
- package/src/core/html/semantic-enhancer-mappings.js +200 -0
- package/src/core/html/semantic-enhancer-page.js +148 -0
- package/src/core/html/semantic-enhancer.js +135 -0
- package/src/core/links/rewrite-links-css-rewriter.js +53 -0
- package/src/core/links/rewrite-links.js +173 -0
- package/src/core/media/asset-validator.js +118 -0
- package/src/core/media/extract-assets-downloader.js +187 -0
- package/src/core/media/extract-assets-page-scraper.js +115 -0
- package/src/core/media/extract-assets.js +159 -0
- package/src/core/media/video-capture-convert.js +200 -0
- package/src/core/media/video-capture.js +201 -0
- package/src/core/{lazy-loader.js → page-prep/lazy-loader.js} +37 -39
- package/src/core/section/section-cropper-helpers.js +43 -0
- package/src/core/{section-cropper.js → section/section-cropper.js} +11 -88
- package/src/core/section/section-detector-strategies.js +139 -0
- package/src/core/section/section-detector-utils.js +100 -0
- package/src/core/section/section-detector.js +88 -0
- package/src/core/tests/test-section-cropper.js +2 -2
- package/src/core/tests/test-section-detector.js +2 -2
- package/src/post-process/enhance-assets.js +29 -4
- package/src/post-process/fetch-images-unsplash-client.js +123 -0
- package/src/post-process/fetch-images.js +60 -263
- package/src/post-process/inject-gosnap.js +88 -0
- package/src/post-process/inject-icons-svg-replacer.js +76 -0
- package/src/post-process/inject-icons.js +47 -200
- package/src/route-discoverers/base-discoverer-utils.js +137 -0
- package/src/route-discoverers/base-discoverer.js +29 -118
- package/src/route-discoverers/index.js +1 -1
- package/src/shared/config.js +38 -0
- package/src/shared/error-codes.js +31 -0
- package/src/shared/viewports.js +46 -0
- package/src/utils/browser.js +0 -7
- package/src/utils/helpers.js +4 -0
- package/src/utils/log.js +12 -0
- package/src/utils/playwright-loader.js +76 -0
- package/src/utils/playwright.js +3 -69
- package/src/utils/progress.js +32 -0
- package/src/verification/generate-audit-report-css-fixes.js +52 -0
- package/src/verification/generate-audit-report-sections.js +158 -0
- package/src/verification/generate-audit-report.js +5 -281
- package/src/verification/quality-scorer.js +92 -0
- package/src/verification/verify-footer-checks.js +103 -0
- package/src/verification/verify-footer-helpers.js +178 -0
- package/src/verification/verify-footer.js +23 -381
- package/src/verification/verify-header-checks.js +104 -0
- package/src/verification/verify-header-helpers.js +156 -0
- package/src/verification/verify-header.js +23 -365
- package/src/verification/verify-layout-report.js +101 -0
- package/src/verification/verify-layout.js +13 -259
- package/src/verification/verify-menu-checks.js +104 -0
- package/src/verification/verify-menu-helpers.js +112 -0
- package/src/verification/verify-menu.js +17 -285
- package/src/verification/verify-slider-checks.js +115 -0
- package/src/verification/verify-slider-constants.js +65 -0
- package/src/verification/verify-slider-helpers.js +164 -0
- package/src/verification/verify-slider.js +23 -414
- package/.env.example +0 -14
- package/docs/basic-clone.md +0 -63
- package/docs/cli-reference.md +0 -316
- package/docs/design-clone-architecture.md +0 -492
- package/docs/pixel-perfect.md +0 -117
- package/docs/project-roadmap.md +0 -382
- package/docs/troubleshooting.md +0 -170
- package/requirements.txt +0 -5
- package/src/ai/__pycache__/analyze-structure.cpython-313.pyc +0 -0
- package/src/ai/__pycache__/extract-design-tokens.cpython-313.pyc +0 -0
- package/src/ai/analyze-structure.py +0 -375
- package/src/ai/extract-design-tokens.py +0 -782
- package/src/ai/prompts/__init__.py +0 -2
- package/src/ai/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/ai/prompts/__pycache__/design_tokens.cpython-313.pyc +0 -0
- package/src/ai/prompts/__pycache__/structure_analysis.cpython-313.pyc +0 -0
- package/src/ai/prompts/__pycache__/ux_audit.cpython-313.pyc +0 -0
- package/src/ai/prompts/design_tokens.py +0 -316
- package/src/ai/prompts/structure_analysis.py +0 -592
- package/src/ai/prompts/ux_audit.py +0 -198
- package/src/ai/ux-audit.js +0 -596
- package/src/core/animation-extractor.js +0 -526
- package/src/core/app-state-snapshot.js +0 -511
- package/src/core/content-counter.js +0 -342
- package/src/core/design-tokens.js +0 -103
- package/src/core/dimension-extractor.js +0 -438
- package/src/core/dimension-output.js +0 -305
- package/src/core/discover-pages.js +0 -542
- package/src/core/dom-tree-analyzer.js +0 -298
- package/src/core/extract-assets.js +0 -468
- package/src/core/filter-css.js +0 -499
- package/src/core/framework-detector.js +0 -538
- package/src/core/html-extractor.js +0 -212
- package/src/core/merge-css.js +0 -407
- package/src/core/multi-page-screenshot.js +0 -380
- package/src/core/rewrite-links.js +0 -226
- package/src/core/screenshot.js +0 -701
- package/src/core/section-detector.js +0 -386
- package/src/core/semantic-enhancer.js +0 -492
- package/src/core/state-capture.js +0 -598
- package/src/core/video-capture.js +0 -546
- package/src/utils/__init__.py +0 -16
- package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/utils/__pycache__/env.cpython-313.pyc +0 -0
- package/src/utils/env.py +0 -134
- /package/src/core/{css-extractor.js → css/css-extractor.js} +0 -0
- /package/src/core/{cookie-handler.js → page-prep/cookie-handler.js} +0 -0
- /package/src/core/{page-readiness.js → page-prep/page-readiness.js} +0 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unsplash API Client
|
|
3
|
+
*
|
|
4
|
+
* Handles searching Unsplash for photos and triggering download attribution.
|
|
5
|
+
* Uses a module-level cache to avoid duplicate API calls within a session.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const UNSPLASH_API = 'https://api.unsplash.com';
|
|
9
|
+
|
|
10
|
+
// Cache for search results to avoid duplicate API calls
|
|
11
|
+
const searchCache = new Map();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Generate search keywords from image context.
|
|
15
|
+
* Prioritises alt text; falls back to surrounding text words.
|
|
16
|
+
* Translates common Japanese keywords for better Unsplash results.
|
|
17
|
+
* @param {{ alt: string, context: string }} imageContext
|
|
18
|
+
* @returns {string} Keyword string
|
|
19
|
+
*/
|
|
20
|
+
export function generateKeywords(imageContext) {
|
|
21
|
+
const { alt, context } = imageContext;
|
|
22
|
+
let keywords = alt;
|
|
23
|
+
|
|
24
|
+
if (!keywords || keywords.length < 3) {
|
|
25
|
+
const words = context
|
|
26
|
+
.toLowerCase()
|
|
27
|
+
.replace(/[^\w\s]/g, '')
|
|
28
|
+
.split(/\s+/)
|
|
29
|
+
.filter(w => w.length > 3)
|
|
30
|
+
.filter(w => !['http', 'https', 'www', 'html', 'class', 'style'].includes(w));
|
|
31
|
+
keywords = words.slice(0, 3).join(' ');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const translations = {
|
|
35
|
+
'会社': 'company office',
|
|
36
|
+
'仕事': 'work business',
|
|
37
|
+
'人': 'people team',
|
|
38
|
+
'サービス': 'service',
|
|
39
|
+
'ビジネス': 'business',
|
|
40
|
+
'技術': 'technology',
|
|
41
|
+
'オフィス': 'office',
|
|
42
|
+
'チーム': 'team',
|
|
43
|
+
'ミーティング': 'meeting',
|
|
44
|
+
'開発': 'development',
|
|
45
|
+
'デザイン': 'design',
|
|
46
|
+
'マーケティング': 'marketing',
|
|
47
|
+
'事例': 'case study business',
|
|
48
|
+
'導入': 'implementation',
|
|
49
|
+
'CTA': 'business success'
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
for (const [jp, en] of Object.entries(translations)) {
|
|
53
|
+
if (keywords.includes(jp)) keywords = keywords.replace(jp, en);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!keywords || keywords.length < 3) keywords = 'business professional';
|
|
57
|
+
|
|
58
|
+
return keywords.trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Search Unsplash for an image matching the keywords.
|
|
63
|
+
* Returns null if no API key or no results found.
|
|
64
|
+
* @param {string} keywords
|
|
65
|
+
* @param {'landscape'|'portrait'|'squarish'} orientation
|
|
66
|
+
* @returns {Promise<{ id, url, thumb, photographer, photographerUrl, downloadLocation }|null>}
|
|
67
|
+
*/
|
|
68
|
+
export async function searchUnsplash(keywords, orientation = 'landscape') {
|
|
69
|
+
const apiKey = process.env.UNSPLASH_ACCESS_KEY;
|
|
70
|
+
if (!apiKey) return null;
|
|
71
|
+
|
|
72
|
+
const cacheKey = `${keywords}-${orientation}`;
|
|
73
|
+
if (searchCache.has(cacheKey)) return searchCache.get(cacheKey);
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const params = new URLSearchParams({ query: keywords, orientation, per_page: '1' });
|
|
77
|
+
const response = await fetch(`${UNSPLASH_API}/search/photos?${params}`, {
|
|
78
|
+
headers: {
|
|
79
|
+
'Authorization': `Client-ID ${apiKey}`,
|
|
80
|
+
'Accept-Version': 'v1'
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
if (response.status === 403) console.warn(' ⚠ Unsplash rate limit reached');
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const data = await response.json();
|
|
90
|
+
if (!data.results || data.results.length === 0) return null;
|
|
91
|
+
|
|
92
|
+
const photo = data.results[0];
|
|
93
|
+
const result = {
|
|
94
|
+
id: photo.id,
|
|
95
|
+
url: photo.urls.regular,
|
|
96
|
+
thumb: photo.urls.thumb,
|
|
97
|
+
photographer: photo.user.name,
|
|
98
|
+
photographerUrl: photo.user.links.html,
|
|
99
|
+
downloadLocation: photo.links.download_location
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
searchCache.set(cacheKey, result);
|
|
103
|
+
return result;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
console.warn(` ⚠ Unsplash search failed: ${error.message}`);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Trigger download event for attribution (required by Unsplash API terms).
|
|
112
|
+
* Silently ignores failures — not critical.
|
|
113
|
+
* @param {string} downloadLocation
|
|
114
|
+
*/
|
|
115
|
+
export async function triggerDownload(downloadLocation) {
|
|
116
|
+
const apiKey = process.env.UNSPLASH_ACCESS_KEY;
|
|
117
|
+
if (!apiKey || !downloadLocation) return;
|
|
118
|
+
try {
|
|
119
|
+
await fetch(downloadLocation, {
|
|
120
|
+
headers: { 'Authorization': `Client-ID ${apiKey}` }
|
|
121
|
+
});
|
|
122
|
+
} catch { /* silently fail */ }
|
|
123
|
+
}
|
|
@@ -20,8 +20,9 @@
|
|
|
20
20
|
|
|
21
21
|
import fs from 'fs/promises';
|
|
22
22
|
import path from 'path';
|
|
23
|
+
import { parseArgs as parseRawArgs } from '../utils/helpers.js';
|
|
24
|
+
import { generateKeywords, searchUnsplash, triggerDownload } from './fetch-images-unsplash-client.js';
|
|
23
25
|
|
|
24
|
-
const UNSPLASH_API = 'https://api.unsplash.com';
|
|
25
26
|
const PLACEHOLDER_PATTERNS = [
|
|
26
27
|
/https?:\/\/placehold\.co\/[^"'\s)]+/gi,
|
|
27
28
|
/https?:\/\/placeholder\.com\/[^"'\s)]+/gi,
|
|
@@ -29,272 +30,107 @@ const PLACEHOLDER_PATTERNS = [
|
|
|
29
30
|
/https?:\/\/picsum\.photos\/[^"'\s)]+/gi
|
|
30
31
|
];
|
|
31
32
|
|
|
32
|
-
// Cache for search results to avoid duplicate API calls
|
|
33
|
-
const searchCache = new Map();
|
|
34
|
-
|
|
35
33
|
/**
|
|
36
|
-
* Parse command line arguments
|
|
34
|
+
* Parse command line arguments.
|
|
35
|
+
* @returns {{ html: string|null, output: string|null, verbose: boolean }}
|
|
37
36
|
*/
|
|
38
37
|
function parseArgs() {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
html: null,
|
|
42
|
-
output: null,
|
|
43
|
-
verbose:
|
|
38
|
+
const raw = parseRawArgs(process.argv.slice(2));
|
|
39
|
+
return {
|
|
40
|
+
html: raw.html || null,
|
|
41
|
+
output: raw.output || null,
|
|
42
|
+
verbose: raw.verbose === true || raw.verbose === 'true'
|
|
44
43
|
};
|
|
45
|
-
|
|
46
|
-
for (let i = 0; i < args.length; i++) {
|
|
47
|
-
switch (args[i]) {
|
|
48
|
-
case '--html':
|
|
49
|
-
options.html = args[++i];
|
|
50
|
-
break;
|
|
51
|
-
case '--output':
|
|
52
|
-
options.output = args[++i];
|
|
53
|
-
break;
|
|
54
|
-
case '--verbose':
|
|
55
|
-
options.verbose = true;
|
|
56
|
-
break;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return options;
|
|
61
44
|
}
|
|
62
45
|
|
|
63
46
|
/**
|
|
64
|
-
* Extract image contexts from HTML
|
|
65
|
-
*
|
|
47
|
+
* Extract image contexts from HTML.
|
|
48
|
+
* @param {string} html
|
|
49
|
+
* @returns {Array<{ placeholder, alt, context, orientation, originalTag }>}
|
|
66
50
|
*/
|
|
67
|
-
function extractImageContexts(html) {
|
|
51
|
+
export function extractImageContexts(html) {
|
|
68
52
|
const contexts = [];
|
|
69
53
|
const imgRegex = /<img[^>]*>/gi;
|
|
70
|
-
const matches = html.matchAll(imgRegex);
|
|
71
54
|
|
|
72
|
-
for (const match of
|
|
55
|
+
for (const match of html.matchAll(imgRegex)) {
|
|
73
56
|
const imgTag = match[0];
|
|
74
57
|
|
|
75
|
-
// Check if src is a placeholder
|
|
76
58
|
let placeholderUrl = null;
|
|
77
59
|
for (const pattern of PLACEHOLDER_PATTERNS) {
|
|
78
60
|
const srcMatch = imgTag.match(pattern);
|
|
79
|
-
if (srcMatch) {
|
|
80
|
-
placeholderUrl = srcMatch[0];
|
|
81
|
-
break;
|
|
82
|
-
}
|
|
61
|
+
if (srcMatch) { placeholderUrl = srcMatch[0]; break; }
|
|
83
62
|
}
|
|
84
|
-
|
|
85
63
|
if (!placeholderUrl) continue;
|
|
86
64
|
|
|
87
|
-
// Extract alt text
|
|
88
65
|
const altMatch = imgTag.match(/alt=["']([^"']*)["']/i);
|
|
89
|
-
const alt
|
|
66
|
+
const alt = altMatch ? altMatch[1] : '';
|
|
90
67
|
|
|
91
|
-
// Determine orientation from placeholder URL dimensions
|
|
92
68
|
let orientation = 'landscape';
|
|
93
|
-
const dimMatch
|
|
69
|
+
const dimMatch = placeholderUrl.match(/(\d+)x(\d+)/);
|
|
94
70
|
if (dimMatch) {
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
if (
|
|
98
|
-
else if (
|
|
71
|
+
const w = parseInt(dimMatch[1]);
|
|
72
|
+
const h = parseInt(dimMatch[2]);
|
|
73
|
+
if (h > w) orientation = 'portrait';
|
|
74
|
+
else if (h === w) orientation = 'squarish';
|
|
99
75
|
}
|
|
100
76
|
|
|
101
|
-
|
|
102
|
-
const position = match.index;
|
|
77
|
+
const position = match.index;
|
|
103
78
|
const contextStart = Math.max(0, position - 100);
|
|
104
|
-
const contextEnd
|
|
79
|
+
const contextEnd = Math.min(html.length, position + imgTag.length + 100);
|
|
105
80
|
const surroundingText = html.slice(contextStart, contextEnd)
|
|
106
81
|
.replace(/<[^>]+>/g, ' ')
|
|
107
82
|
.replace(/\s+/g, ' ')
|
|
108
83
|
.trim();
|
|
109
84
|
|
|
110
|
-
contexts.push({
|
|
111
|
-
placeholder: placeholderUrl,
|
|
112
|
-
alt: alt,
|
|
113
|
-
context: surroundingText,
|
|
114
|
-
orientation: orientation,
|
|
115
|
-
originalTag: imgTag
|
|
116
|
-
});
|
|
85
|
+
contexts.push({ placeholder: placeholderUrl, alt, context: surroundingText, orientation, originalTag: imgTag });
|
|
117
86
|
}
|
|
118
87
|
|
|
119
88
|
return contexts;
|
|
120
89
|
}
|
|
121
90
|
|
|
122
|
-
/**
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
// Priority: alt text > surrounding context
|
|
129
|
-
let keywords = alt;
|
|
130
|
-
|
|
131
|
-
if (!keywords || keywords.length < 3) {
|
|
132
|
-
// Extract meaningful words from context
|
|
133
|
-
const words = context
|
|
134
|
-
.toLowerCase()
|
|
135
|
-
.replace(/[^\w\s]/g, '')
|
|
136
|
-
.split(/\s+/)
|
|
137
|
-
.filter(w => w.length > 3)
|
|
138
|
-
.filter(w => !['http', 'https', 'www', 'html', 'class', 'style'].includes(w));
|
|
139
|
-
|
|
140
|
-
keywords = words.slice(0, 3).join(' ');
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Translate common Japanese keywords for better Unsplash results
|
|
144
|
-
const translations = {
|
|
145
|
-
'会社': 'company office',
|
|
146
|
-
'仕事': 'work business',
|
|
147
|
-
'人': 'people team',
|
|
148
|
-
'サービス': 'service',
|
|
149
|
-
'ビジネス': 'business',
|
|
150
|
-
'技術': 'technology',
|
|
151
|
-
'オフィス': 'office',
|
|
152
|
-
'チーム': 'team',
|
|
153
|
-
'ミーティング': 'meeting',
|
|
154
|
-
'開発': 'development',
|
|
155
|
-
'デザイン': 'design',
|
|
156
|
-
'マーケティング': 'marketing',
|
|
157
|
-
'事例': 'case study business',
|
|
158
|
-
'導入': 'implementation',
|
|
159
|
-
'CTA': 'business success'
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
for (const [jp, en] of Object.entries(translations)) {
|
|
163
|
-
if (keywords.includes(jp)) {
|
|
164
|
-
keywords = keywords.replace(jp, en);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Default fallback
|
|
169
|
-
if (!keywords || keywords.length < 3) {
|
|
170
|
-
keywords = 'business professional';
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
return keywords.trim();
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Search Unsplash for images
|
|
178
|
-
*/
|
|
179
|
-
async function searchUnsplash(keywords, orientation = 'landscape') {
|
|
180
|
-
const apiKey = process.env.UNSPLASH_ACCESS_KEY;
|
|
181
|
-
|
|
182
|
-
if (!apiKey) {
|
|
183
|
-
return null;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Check cache
|
|
187
|
-
const cacheKey = `${keywords}-${orientation}`;
|
|
188
|
-
if (searchCache.has(cacheKey)) {
|
|
189
|
-
return searchCache.get(cacheKey);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
try {
|
|
193
|
-
const params = new URLSearchParams({
|
|
194
|
-
query: keywords,
|
|
195
|
-
orientation: orientation,
|
|
196
|
-
per_page: '1'
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
const response = await fetch(`${UNSPLASH_API}/search/photos?${params}`, {
|
|
200
|
-
headers: {
|
|
201
|
-
'Authorization': `Client-ID ${apiKey}`,
|
|
202
|
-
'Accept-Version': 'v1'
|
|
203
|
-
}
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
if (!response.ok) {
|
|
207
|
-
if (response.status === 403) {
|
|
208
|
-
console.warn(' ⚠ Unsplash rate limit reached');
|
|
209
|
-
}
|
|
210
|
-
return null;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const data = await response.json();
|
|
214
|
-
|
|
215
|
-
if (data.results && data.results.length > 0) {
|
|
216
|
-
const photo = data.results[0];
|
|
217
|
-
const result = {
|
|
218
|
-
id: photo.id,
|
|
219
|
-
url: photo.urls.regular, // 1080px width
|
|
220
|
-
thumb: photo.urls.thumb,
|
|
221
|
-
photographer: photo.user.name,
|
|
222
|
-
photographerUrl: photo.user.links.html,
|
|
223
|
-
downloadLocation: photo.links.download_location
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
searchCache.set(cacheKey, result);
|
|
227
|
-
return result;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
return null;
|
|
231
|
-
} catch (error) {
|
|
232
|
-
console.warn(` ⚠ Unsplash search failed: ${error.message}`);
|
|
233
|
-
return null;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Trigger download event for attribution (required by Unsplash API)
|
|
239
|
-
*/
|
|
240
|
-
async function triggerDownload(downloadLocation) {
|
|
241
|
-
const apiKey = process.env.UNSPLASH_ACCESS_KEY;
|
|
242
|
-
if (!apiKey || !downloadLocation) return;
|
|
243
|
-
|
|
244
|
-
try {
|
|
245
|
-
await fetch(downloadLocation, {
|
|
246
|
-
headers: {
|
|
247
|
-
'Authorization': `Client-ID ${apiKey}`
|
|
248
|
-
}
|
|
249
|
-
});
|
|
250
|
-
} catch {
|
|
251
|
-
// Silently fail - not critical
|
|
252
|
-
}
|
|
91
|
+
/** Build Unsplash attribution HTML comment block. */
|
|
92
|
+
function addAttributionComment(html, attributions) {
|
|
93
|
+
if (attributions.length === 0) return html;
|
|
94
|
+
const lines = attributions.map(a => ` - "${a.keywords}": Photo by ${a.photographer} (${a.photographerUrl})`).join('\n');
|
|
95
|
+
const comment = `\n<!--\n Image Credits (Unsplash)\n ========================\n${lines}\n\n Licensed under the Unsplash License: https://unsplash.com/license\n-->\n`;
|
|
96
|
+
return html.replace(/<head>/i, `<head>${comment}`);
|
|
253
97
|
}
|
|
254
98
|
|
|
255
99
|
/**
|
|
256
|
-
* Replace placeholder URLs in HTML with Unsplash images
|
|
100
|
+
* Replace placeholder image URLs in HTML with Unsplash images.
|
|
101
|
+
* @param {string} html
|
|
102
|
+
* @param {boolean} verbose
|
|
103
|
+
* @returns {Promise<{ html: string, attributions: Array, replacedCount: number }>}
|
|
257
104
|
*/
|
|
258
105
|
async function replaceImages(html, verbose = false) {
|
|
259
|
-
const contexts
|
|
260
|
-
const attributions
|
|
261
|
-
let updatedHtml
|
|
262
|
-
let replacedCount
|
|
106
|
+
const contexts = extractImageContexts(html);
|
|
107
|
+
const attributions = [];
|
|
108
|
+
let updatedHtml = html;
|
|
109
|
+
let replacedCount = 0;
|
|
263
110
|
|
|
264
|
-
if (verbose) {
|
|
265
|
-
console.log(` Found ${contexts.length} placeholder images`);
|
|
266
|
-
}
|
|
111
|
+
if (verbose) console.log(` Found ${contexts.length} placeholder images`);
|
|
267
112
|
|
|
268
113
|
for (const ctx of contexts) {
|
|
269
114
|
const keywords = generateKeywords(ctx);
|
|
270
|
-
|
|
271
|
-
if (verbose) {
|
|
272
|
-
console.log(` → Searching: "${keywords}" (${ctx.orientation})`);
|
|
273
|
-
}
|
|
115
|
+
if (verbose) console.log(` → Searching: "${keywords}" (${ctx.orientation})`);
|
|
274
116
|
|
|
275
117
|
const photo = await searchUnsplash(keywords, ctx.orientation);
|
|
276
118
|
|
|
277
119
|
if (photo) {
|
|
278
|
-
// Replace placeholder URL with Unsplash URL
|
|
279
120
|
updatedHtml = updatedHtml.replace(ctx.placeholder, photo.url);
|
|
280
121
|
replacedCount++;
|
|
281
122
|
|
|
282
|
-
// Track attribution
|
|
283
123
|
attributions.push({
|
|
284
|
-
keywords
|
|
285
|
-
photoId:
|
|
286
|
-
url:
|
|
287
|
-
photographer:
|
|
124
|
+
keywords,
|
|
125
|
+
photoId: photo.id,
|
|
126
|
+
url: photo.url,
|
|
127
|
+
photographer: photo.photographer,
|
|
288
128
|
photographerUrl: photo.photographerUrl,
|
|
289
|
-
license:
|
|
129
|
+
license: 'Unsplash License'
|
|
290
130
|
});
|
|
291
131
|
|
|
292
|
-
// Trigger download for attribution tracking
|
|
293
132
|
await triggerDownload(photo.downloadLocation);
|
|
294
|
-
|
|
295
|
-
if (verbose) {
|
|
296
|
-
console.log(` ✓ Found: ${photo.photographer}`);
|
|
297
|
-
}
|
|
133
|
+
if (verbose) console.log(` ✓ Found: ${photo.photographer}`);
|
|
298
134
|
} else if (verbose) {
|
|
299
135
|
console.log(` ✗ No results`);
|
|
300
136
|
}
|
|
@@ -307,59 +143,32 @@ async function replaceImages(html, verbose = false) {
|
|
|
307
143
|
}
|
|
308
144
|
|
|
309
145
|
/**
|
|
310
|
-
*
|
|
146
|
+
* Main function — reads HTML, replaces images, writes results.
|
|
147
|
+
* @param {string} htmlPath
|
|
148
|
+
* @param {string} outputDir
|
|
149
|
+
* @param {boolean} verbose
|
|
150
|
+
* @returns {Promise<{ success: boolean, replacedCount?: number, attributions?: Array, skipped?: boolean }>}
|
|
311
151
|
*/
|
|
312
|
-
function
|
|
313
|
-
if (attributions.length === 0) return html;
|
|
314
|
-
|
|
315
|
-
const comment = `
|
|
316
|
-
<!--
|
|
317
|
-
Image Credits (Unsplash)
|
|
318
|
-
========================
|
|
319
|
-
${attributions.map(a => ` - "${a.keywords}": Photo by ${a.photographer} (${a.photographerUrl})`).join('\n')}
|
|
320
|
-
|
|
321
|
-
Licensed under the Unsplash License: https://unsplash.com/license
|
|
322
|
-
-->
|
|
323
|
-
`;
|
|
324
|
-
|
|
325
|
-
// Insert after <head> tag
|
|
326
|
-
return html.replace(/<head>/i, `<head>${comment}`);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
/**
|
|
330
|
-
* Main function
|
|
331
|
-
*/
|
|
332
|
-
async function fetchImages(htmlPath, outputDir, verbose = false) {
|
|
152
|
+
export async function fetchImages(htmlPath, outputDir, verbose = false) {
|
|
333
153
|
const apiKey = process.env.UNSPLASH_ACCESS_KEY;
|
|
334
154
|
|
|
335
155
|
if (!apiKey) {
|
|
336
156
|
console.log(' → Skipping Unsplash (no UNSPLASH_ACCESS_KEY set)');
|
|
337
|
-
return {
|
|
338
|
-
success: true,
|
|
339
|
-
skipped: true,
|
|
340
|
-
message: 'No API key configured'
|
|
341
|
-
};
|
|
157
|
+
return { success: true, skipped: true, message: 'No API key configured' };
|
|
342
158
|
}
|
|
343
159
|
|
|
344
|
-
// Read HTML
|
|
345
160
|
const html = await fs.readFile(htmlPath, 'utf-8');
|
|
346
|
-
|
|
347
|
-
// Replace images
|
|
348
161
|
const { html: updatedHtml, attributions, replacedCount } = await replaceImages(html, verbose);
|
|
349
162
|
|
|
350
163
|
if (replacedCount > 0) {
|
|
351
|
-
// Add attribution comment
|
|
352
164
|
const finalHtml = addAttributionComment(updatedHtml, attributions);
|
|
353
|
-
|
|
354
|
-
// Write updated HTML
|
|
355
165
|
await fs.writeFile(htmlPath, finalHtml, 'utf-8');
|
|
356
166
|
|
|
357
|
-
// Write attribution JSON
|
|
358
167
|
const attrPath = path.join(outputDir, 'attribution.json');
|
|
359
168
|
await fs.writeFile(attrPath, JSON.stringify({
|
|
360
169
|
generated: new Date().toISOString(),
|
|
361
|
-
source:
|
|
362
|
-
images:
|
|
170
|
+
source: 'Unsplash',
|
|
171
|
+
images: attributions
|
|
363
172
|
}, null, 2), 'utf-8');
|
|
364
173
|
|
|
365
174
|
console.log(` ✓ Replaced ${replacedCount} images from Unsplash`);
|
|
@@ -367,32 +176,20 @@ async function fetchImages(htmlPath, outputDir, verbose = false) {
|
|
|
367
176
|
console.log(' → No placeholder images found to replace');
|
|
368
177
|
}
|
|
369
178
|
|
|
370
|
-
return {
|
|
371
|
-
success: true,
|
|
372
|
-
replacedCount,
|
|
373
|
-
attributions
|
|
374
|
-
};
|
|
179
|
+
return { success: true, replacedCount, attributions };
|
|
375
180
|
}
|
|
376
181
|
|
|
377
182
|
// CLI execution
|
|
378
183
|
if (process.argv[1] === new URL(import.meta.url).pathname) {
|
|
379
|
-
const args
|
|
184
|
+
const args = parseArgs();
|
|
185
|
+
const outputDir = args.output || path.dirname(args.html);
|
|
380
186
|
|
|
381
187
|
if (!args.html) {
|
|
382
188
|
console.error('Usage: node fetch-images.js --html <path> --output <dir>');
|
|
383
189
|
process.exit(1);
|
|
384
190
|
}
|
|
385
191
|
|
|
386
|
-
const outputDir = args.output || path.dirname(args.html);
|
|
387
|
-
|
|
388
192
|
fetchImages(args.html, outputDir, args.verbose)
|
|
389
|
-
.then(result =>
|
|
390
|
-
|
|
391
|
-
})
|
|
392
|
-
.catch(error => {
|
|
393
|
-
console.error('Error:', error.message);
|
|
394
|
-
process.exit(1);
|
|
395
|
-
});
|
|
193
|
+
.then(result => console.log(JSON.stringify(result, null, 2)))
|
|
194
|
+
.catch(error => { console.error('Error:', error.message); process.exit(1); });
|
|
396
195
|
}
|
|
397
|
-
|
|
398
|
-
export { fetchImages, extractImageContexts, generateKeywords };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* GoSnap Widget Injector for Design Clone
|
|
4
|
+
*
|
|
5
|
+
* Injects gosnap-widget Web Component into generated HTML files.
|
|
6
|
+
* Scans pages/ directory and adds <go-snap> element before </body>.
|
|
7
|
+
* Idempotent -- skips files that already contain the widget.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* node inject-gosnap.js --dir ./pages [--verbose]
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'fs/promises';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
|
|
16
|
+
const GOSNAP_TAG = 'go-snap';
|
|
17
|
+
const GOSNAP_SNIPPET = `<script src="https://unpkg.com/gosnap-widget@1.0.1/dist/embed.global.js"></script>
|
|
18
|
+
<go-snap position="bottom-right" theme="dark" persist></go-snap>`;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Inject gosnap-widget into all HTML files in a directory
|
|
22
|
+
* @param {string} pagesDir - Path to directory containing HTML files
|
|
23
|
+
* @param {boolean} [verbose=false] - Show detailed progress
|
|
24
|
+
* @returns {Promise<{success: boolean, injectedCount: number, skippedCount: number}>}
|
|
25
|
+
*/
|
|
26
|
+
async function injectGosnap(pagesDir, verbose = false) {
|
|
27
|
+
// Guard: check directory exists
|
|
28
|
+
try {
|
|
29
|
+
await fs.access(pagesDir);
|
|
30
|
+
} catch {
|
|
31
|
+
throw new Error(`Directory not found: ${pagesDir}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const entries = await fs.readdir(pagesDir);
|
|
35
|
+
const htmlFiles = entries.filter(f => f.endsWith('.html'));
|
|
36
|
+
|
|
37
|
+
let injectedCount = 0;
|
|
38
|
+
let skippedCount = 0;
|
|
39
|
+
|
|
40
|
+
for (const file of htmlFiles) {
|
|
41
|
+
const filePath = path.join(pagesDir, file);
|
|
42
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
43
|
+
|
|
44
|
+
if (content.includes(GOSNAP_TAG)) {
|
|
45
|
+
skippedCount++;
|
|
46
|
+
if (verbose) console.log(` -> Skipped (already present): ${file}`);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!content.includes('</body>')) {
|
|
51
|
+
skippedCount++;
|
|
52
|
+
if (verbose) console.log(` -> Skipped (no </body> tag): ${file}`);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const updated = content.replace('</body>', `\n${GOSNAP_SNIPPET}\n</body>`);
|
|
57
|
+
await fs.writeFile(filePath, updated, 'utf-8');
|
|
58
|
+
injectedCount++;
|
|
59
|
+
if (verbose) console.log(` -> Injected: ${file}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log(` Injected gosnap-widget into ${injectedCount} file(s), skipped ${skippedCount}`);
|
|
63
|
+
|
|
64
|
+
return { success: true, injectedCount, skippedCount };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// CLI execution
|
|
68
|
+
if (process.argv[1] === new URL(import.meta.url).pathname) {
|
|
69
|
+
const args = process.argv.slice(2);
|
|
70
|
+
let dir = null;
|
|
71
|
+
let verbose = false;
|
|
72
|
+
|
|
73
|
+
for (let i = 0; i < args.length; i++) {
|
|
74
|
+
if (args[i] === '--dir' && args[i + 1]) dir = args[++i];
|
|
75
|
+
else if (args[i] === '--verbose') verbose = true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!dir) {
|
|
79
|
+
console.error('Usage: node inject-gosnap.js --dir <path> [--verbose]');
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
injectGosnap(dir, verbose)
|
|
84
|
+
.then(result => console.log(JSON.stringify(result, null, 2)))
|
|
85
|
+
.catch(error => { console.error('Error:', error.message); process.exit(1); });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export { injectGosnap };
|