gbu-accessibility-package 3.1.3 → 3.2.1
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 +20 -0
- package/ENHANCED_ALT_FEATURES.md +230 -0
- package/README-vi.md +204 -575
- package/README.md +185 -511
- package/cli.js +66 -3
- package/demo/broken-links-test.html +41 -0
- package/demo/enhanced-alt-test.html +150 -0
- package/index.js +1 -6
- package/lib/fixer.js +1517 -12
- package/package.json +9 -4
- package/demo/advanced-test.html.backup +0 -44
- package/demo/backup-test.html +0 -18
- package/demo/backup-test2.html +0 -13
- package/demo/backup-test3.html +0 -12
- package/demo/comprehensive-test.html.backup +0 -21
- package/demo/no-backup-test.html +0 -12
- package/demo/no-backup-test.html.backup +0 -12
package/lib/fixer.js
CHANGED
|
@@ -7,14 +7,1253 @@ const fs = require('fs').promises;
|
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const chalk = require('chalk');
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Enhanced Alt Text Generator
|
|
12
|
+
* Tạo alt text thông minh và đa dạng dựa trên AI và ngữ cảnh
|
|
13
|
+
*/
|
|
14
|
+
class EnhancedAltGenerator {
|
|
15
|
+
constructor(config = {}) {
|
|
16
|
+
this.config = {
|
|
17
|
+
language: config.language || 'ja',
|
|
18
|
+
creativity: config.creativity || 'balanced', // conservative, balanced, creative
|
|
19
|
+
includeEmotions: config.includeEmotions || false,
|
|
20
|
+
includeBrandContext: config.includeBrandContext || true,
|
|
21
|
+
maxLength: config.maxLength || 125,
|
|
22
|
+
...config
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Từ điển đa ngôn ngữ
|
|
26
|
+
this.vocabulary = this.initializeVocabulary();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
initializeVocabulary() {
|
|
30
|
+
return {
|
|
31
|
+
ja: {
|
|
32
|
+
types: {
|
|
33
|
+
person: ['人物', '人', '男性', '女性', '子供', '大人'],
|
|
34
|
+
object: ['物', '商品', 'アイテム', '製品'],
|
|
35
|
+
nature: ['自然', '風景', '景色', '環境'],
|
|
36
|
+
building: ['建物', '建築', '構造物', '施設'],
|
|
37
|
+
food: ['食べ物', '料理', '食品', 'グルメ'],
|
|
38
|
+
technology: ['技術', 'テクノロジー', '機器', 'デバイス'],
|
|
39
|
+
art: ['芸術', 'アート', '作品', 'デザイン'],
|
|
40
|
+
vehicle: ['乗り物', '車両', '交通手段']
|
|
41
|
+
},
|
|
42
|
+
emotions: {
|
|
43
|
+
positive: ['明るい', '楽しい', '美しい', '素晴らしい', '魅力的な'],
|
|
44
|
+
neutral: ['シンプルな', '清潔な', '整然とした', 'プロフェッショナルな'],
|
|
45
|
+
dynamic: ['活気のある', 'エネルギッシュな', 'ダイナミックな', '力強い']
|
|
46
|
+
},
|
|
47
|
+
actions: {
|
|
48
|
+
showing: ['示している', '表示している', '見せている'],
|
|
49
|
+
working: ['作業している', '働いている', '取り組んでいる'],
|
|
50
|
+
enjoying: ['楽しんでいる', '満喫している', '味わっている'],
|
|
51
|
+
creating: ['作成している', '制作している', '開発している']
|
|
52
|
+
},
|
|
53
|
+
contexts: {
|
|
54
|
+
business: ['ビジネス', '企業', '会社', '職場'],
|
|
55
|
+
education: ['教育', '学習', '研修', 'トレーニング'],
|
|
56
|
+
lifestyle: ['ライフスタイル', '日常', '生活', '暮らし'],
|
|
57
|
+
technology: ['IT', 'デジタル', 'オンライン', 'ウェブ']
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
en: {
|
|
61
|
+
types: {
|
|
62
|
+
person: ['person', 'people', 'individual', 'team', 'group'],
|
|
63
|
+
object: ['object', 'item', 'product', 'tool', 'equipment'],
|
|
64
|
+
nature: ['nature', 'landscape', 'scenery', 'environment'],
|
|
65
|
+
building: ['building', 'architecture', 'structure', 'facility'],
|
|
66
|
+
food: ['food', 'cuisine', 'dish', 'meal', 'delicacy'],
|
|
67
|
+
technology: ['technology', 'device', 'gadget', 'equipment'],
|
|
68
|
+
art: ['art', 'artwork', 'design', 'creation'],
|
|
69
|
+
vehicle: ['vehicle', 'transportation', 'automobile']
|
|
70
|
+
},
|
|
71
|
+
emotions: {
|
|
72
|
+
positive: ['bright', 'cheerful', 'beautiful', 'wonderful', 'attractive'],
|
|
73
|
+
neutral: ['simple', 'clean', 'organized', 'professional'],
|
|
74
|
+
dynamic: ['vibrant', 'energetic', 'dynamic', 'powerful']
|
|
75
|
+
},
|
|
76
|
+
actions: {
|
|
77
|
+
showing: ['showing', 'displaying', 'presenting'],
|
|
78
|
+
working: ['working', 'operating', 'engaging'],
|
|
79
|
+
enjoying: ['enjoying', 'experiencing', 'savoring'],
|
|
80
|
+
creating: ['creating', 'developing', 'building']
|
|
81
|
+
},
|
|
82
|
+
contexts: {
|
|
83
|
+
business: ['business', 'corporate', 'company', 'workplace'],
|
|
84
|
+
education: ['education', 'learning', 'training', 'academic'],
|
|
85
|
+
lifestyle: ['lifestyle', 'daily life', 'personal', 'casual'],
|
|
86
|
+
technology: ['technology', 'digital', 'online', 'web']
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
vi: {
|
|
90
|
+
types: {
|
|
91
|
+
person: ['người', 'con người', 'cá nhân', 'nhóm', 'đội ngũ'],
|
|
92
|
+
object: ['vật', 'đồ vật', 'sản phẩm', 'công cụ', 'thiết bị'],
|
|
93
|
+
nature: ['thiên nhiên', 'phong cảnh', 'cảnh quan', 'môi trường'],
|
|
94
|
+
building: ['tòa nhà', 'kiến trúc', 'công trình', 'cơ sở'],
|
|
95
|
+
food: ['thức ăn', 'món ăn', 'ẩm thực', 'đặc sản'],
|
|
96
|
+
technology: ['công nghệ', 'thiết bị', 'máy móc', 'kỹ thuật'],
|
|
97
|
+
art: ['nghệ thuật', 'tác phẩm', 'thiết kế', 'sáng tạo'],
|
|
98
|
+
vehicle: ['phương tiện', 'xe cộ', 'giao thông']
|
|
99
|
+
},
|
|
100
|
+
emotions: {
|
|
101
|
+
positive: ['tươi sáng', 'vui vẻ', 'đẹp đẽ', 'tuyệt vời', 'hấp dẫn'],
|
|
102
|
+
neutral: ['đơn giản', 'sạch sẽ', 'ngăn nắp', 'chuyên nghiệp'],
|
|
103
|
+
dynamic: ['sôi động', 'năng động', 'mạnh mẽ', 'đầy năng lượng']
|
|
104
|
+
},
|
|
105
|
+
actions: {
|
|
106
|
+
showing: ['đang hiển thị', 'đang trình bày', 'đang thể hiện'],
|
|
107
|
+
working: ['đang làm việc', 'đang hoạt động', 'đang thực hiện'],
|
|
108
|
+
enjoying: ['đang thưởng thức', 'đang tận hưởng', 'đang trải nghiệm'],
|
|
109
|
+
creating: ['đang tạo ra', 'đang phát triển', 'đang xây dựng']
|
|
110
|
+
},
|
|
111
|
+
contexts: {
|
|
112
|
+
business: ['kinh doanh', 'doanh nghiệp', 'công ty', 'nơi làm việc'],
|
|
113
|
+
education: ['giáo dục', 'học tập', 'đào tạo', 'học thuật'],
|
|
114
|
+
lifestyle: ['lối sống', 'cuộc sống', 'cá nhân', 'thường ngày'],
|
|
115
|
+
technology: ['công nghệ', 'số hóa', 'trực tuyến', 'web']
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
generateDiverseAltText(imgTag, htmlContent, analysis) {
|
|
122
|
+
const strategies = [
|
|
123
|
+
() => this.generateContextualAlt(analysis),
|
|
124
|
+
() => this.generateSemanticAlt(analysis),
|
|
125
|
+
() => this.generateEmotionalAlt(analysis),
|
|
126
|
+
() => this.generateActionBasedAlt(analysis),
|
|
127
|
+
() => this.generateBrandAwareAlt(analysis),
|
|
128
|
+
() => this.generateTechnicalAlt(analysis)
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
const selectedStrategies = this.selectStrategies(strategies, analysis);
|
|
132
|
+
|
|
133
|
+
for (const strategy of selectedStrategies) {
|
|
134
|
+
const result = strategy();
|
|
135
|
+
if (result && this.validateAltText(result)) {
|
|
136
|
+
return this.refineAltText(result, analysis);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return this.generateFallbackAlt(analysis);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
generateContextualAlt(analysis) {
|
|
144
|
+
const { context, structural, imageType } = analysis;
|
|
145
|
+
|
|
146
|
+
if (structural.figcaption) {
|
|
147
|
+
return this.enhanceWithVocabulary(structural.figcaption, imageType);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (structural.parentLink) {
|
|
151
|
+
const linkText = this.extractLinkText(structural.parentLink);
|
|
152
|
+
if (linkText) {
|
|
153
|
+
return this.createLinkAlt(linkText, imageType);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const contextElements = this.extractContextElements(context);
|
|
158
|
+
if (contextElements.nearbyHeading) {
|
|
159
|
+
return this.createHeadingBasedAlt(contextElements.nearbyHeading, imageType);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (contextElements.surroundingText) {
|
|
163
|
+
return this.createTextBasedAlt(contextElements.surroundingText, imageType);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
generateSemanticAlt(analysis) {
|
|
170
|
+
const { src, imageType, context } = analysis;
|
|
171
|
+
const lang = this.config.language;
|
|
172
|
+
const vocab = this.vocabulary[lang];
|
|
173
|
+
|
|
174
|
+
const semanticInfo = this.analyzeSemanticContent(src, context);
|
|
175
|
+
|
|
176
|
+
if (!semanticInfo.mainSubject) return null;
|
|
177
|
+
|
|
178
|
+
let altParts = [];
|
|
179
|
+
|
|
180
|
+
const subjectWord = this.selectVocabularyWord(vocab.types[semanticInfo.category] || [], 'random');
|
|
181
|
+
if (subjectWord) {
|
|
182
|
+
altParts.push(subjectWord);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (semanticInfo.description) {
|
|
186
|
+
altParts.push(semanticInfo.description);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (semanticInfo.context && this.config.includeBrandContext) {
|
|
190
|
+
const contextWord = this.selectVocabularyWord(vocab.contexts[semanticInfo.context] || [], 'first');
|
|
191
|
+
if (contextWord) {
|
|
192
|
+
altParts.push(`${contextWord}の`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return this.combineAltParts(altParts);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
generateEmotionalAlt(analysis) {
|
|
200
|
+
if (!this.config.includeEmotions) return null;
|
|
201
|
+
|
|
202
|
+
const { imageType, context } = analysis;
|
|
203
|
+
const lang = this.config.language;
|
|
204
|
+
const vocab = this.vocabulary[lang];
|
|
205
|
+
|
|
206
|
+
const emotionalTone = this.analyzeEmotionalTone(context);
|
|
207
|
+
|
|
208
|
+
if (!emotionalTone) return null;
|
|
209
|
+
|
|
210
|
+
const emotionWords = vocab.emotions[emotionalTone] || [];
|
|
211
|
+
const emotionWord = this.selectVocabularyWord(emotionWords, 'random');
|
|
212
|
+
|
|
213
|
+
if (!emotionWord) return null;
|
|
214
|
+
|
|
215
|
+
const baseAlt = this.generateBasicAlt(analysis);
|
|
216
|
+
|
|
217
|
+
return lang === 'ja' ?
|
|
218
|
+
`${emotionWord}${baseAlt}` :
|
|
219
|
+
`${emotionWord} ${baseAlt}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
generateActionBasedAlt(analysis) {
|
|
223
|
+
const { context, imageType } = analysis;
|
|
224
|
+
const lang = this.config.language;
|
|
225
|
+
const vocab = this.vocabulary[lang];
|
|
226
|
+
|
|
227
|
+
const detectedAction = this.detectAction(context);
|
|
228
|
+
|
|
229
|
+
if (!detectedAction) return null;
|
|
230
|
+
|
|
231
|
+
const actionWords = vocab.actions[detectedAction] || [];
|
|
232
|
+
const actionWord = this.selectVocabularyWord(actionWords, 'random');
|
|
233
|
+
|
|
234
|
+
if (!actionWord) return null;
|
|
235
|
+
|
|
236
|
+
const subject = this.detectSubject(context, imageType);
|
|
237
|
+
|
|
238
|
+
return lang === 'ja' ?
|
|
239
|
+
`${subject}${actionWord}様子` :
|
|
240
|
+
`${subject} ${actionWord}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
generateBrandAwareAlt(analysis) {
|
|
244
|
+
if (!this.config.includeBrandContext) return null;
|
|
245
|
+
|
|
246
|
+
const { context, src } = analysis;
|
|
247
|
+
|
|
248
|
+
const brandInfo = this.extractBrandInfo(context, src);
|
|
249
|
+
|
|
250
|
+
if (!brandInfo.name) return null;
|
|
251
|
+
|
|
252
|
+
const baseAlt = this.generateBasicAlt(analysis);
|
|
253
|
+
|
|
254
|
+
return `${brandInfo.name}の${baseAlt}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
generateTechnicalAlt(analysis) {
|
|
258
|
+
const { imageType, src, context } = analysis;
|
|
259
|
+
|
|
260
|
+
if (imageType !== 'data-visualization' && imageType !== 'complex') {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const technicalInfo = this.extractTechnicalInfo(context, src);
|
|
265
|
+
|
|
266
|
+
if (!technicalInfo.type) return null;
|
|
267
|
+
|
|
268
|
+
let altParts = [technicalInfo.type];
|
|
269
|
+
|
|
270
|
+
if (technicalInfo.data) {
|
|
271
|
+
altParts.push(technicalInfo.data);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (technicalInfo.trend) {
|
|
275
|
+
altParts.push(technicalInfo.trend);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return this.combineAltParts(altParts);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
selectStrategies(strategies, analysis) {
|
|
282
|
+
const { creativity } = this.config;
|
|
283
|
+
const { imageType } = analysis;
|
|
284
|
+
|
|
285
|
+
switch (creativity) {
|
|
286
|
+
case 'conservative':
|
|
287
|
+
return strategies.slice(0, 2);
|
|
288
|
+
case 'creative':
|
|
289
|
+
return strategies;
|
|
290
|
+
default:
|
|
291
|
+
if (imageType === 'decorative') {
|
|
292
|
+
return strategies.slice(0, 1);
|
|
293
|
+
} else if (imageType === 'data-visualization') {
|
|
294
|
+
return [strategies[0], strategies[5]];
|
|
295
|
+
} else {
|
|
296
|
+
return strategies.slice(0, 4);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
validateAltText(altText) {
|
|
302
|
+
if (!altText || typeof altText !== 'string') return false;
|
|
303
|
+
|
|
304
|
+
const trimmed = altText.trim();
|
|
305
|
+
|
|
306
|
+
if (trimmed.length < 2 || trimmed.length > this.config.maxLength) {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const forbiddenWords = ['image', 'picture', 'photo', '画像', '写真'];
|
|
311
|
+
const hasForbidenWord = forbiddenWords.some(word =>
|
|
312
|
+
trimmed.toLowerCase().includes(word.toLowerCase())
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
if (hasForbidenWord) return false;
|
|
316
|
+
|
|
317
|
+
const placeholders = ['[', ']', 'placeholder', 'dummy'];
|
|
318
|
+
const hasPlaceholder = placeholders.some(placeholder =>
|
|
319
|
+
trimmed.toLowerCase().includes(placeholder)
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
return !hasPlaceholder;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
refineAltText(altText, analysis) {
|
|
326
|
+
let refined = altText.trim();
|
|
327
|
+
|
|
328
|
+
refined = refined.replace(/[<>]/g, '');
|
|
329
|
+
refined = refined.replace(/\s+/g, ' ');
|
|
330
|
+
|
|
331
|
+
if (refined.length > this.config.maxLength) {
|
|
332
|
+
refined = refined.substring(0, this.config.maxLength - 3) + '...';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (this.config.language === 'en') {
|
|
336
|
+
refined = refined.charAt(0).toUpperCase() + refined.slice(1);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return refined;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Helper methods
|
|
343
|
+
extractContextElements(context) {
|
|
344
|
+
return {
|
|
345
|
+
nearbyHeading: this.findNearbyHeading(context),
|
|
346
|
+
surroundingText: this.extractSurroundingText(context),
|
|
347
|
+
listContext: this.findListContext(context),
|
|
348
|
+
tableContext: this.findTableContext(context)
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
analyzeSemanticContent(src, context) {
|
|
353
|
+
const srcLower = (src || '').toLowerCase();
|
|
354
|
+
const contextLower = context.toLowerCase();
|
|
355
|
+
|
|
356
|
+
let category = 'object';
|
|
357
|
+
|
|
358
|
+
if (this.containsKeywords(srcLower + ' ' + contextLower, ['person', 'people', 'man', 'woman', '人', '人物'])) {
|
|
359
|
+
category = 'person';
|
|
360
|
+
} else if (this.containsKeywords(srcLower + ' ' + contextLower, ['nature', 'landscape', '自然', '風景'])) {
|
|
361
|
+
category = 'nature';
|
|
362
|
+
} else if (this.containsKeywords(srcLower + ' ' + contextLower, ['building', 'architecture', '建物', '建築'])) {
|
|
363
|
+
category = 'building';
|
|
364
|
+
} else if (this.containsKeywords(srcLower + ' ' + contextLower, ['food', 'restaurant', '食べ物', '料理'])) {
|
|
365
|
+
category = 'food';
|
|
366
|
+
} else if (this.containsKeywords(srcLower + ' ' + contextLower, ['tech', 'computer', 'device', '技術', 'コンピューター'])) {
|
|
367
|
+
category = 'technology';
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
category,
|
|
372
|
+
mainSubject: this.extractMainSubject(context),
|
|
373
|
+
description: this.extractDescription(context),
|
|
374
|
+
context: this.detectContextType(context)
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
analyzeEmotionalTone(context) {
|
|
379
|
+
const contextLower = context.toLowerCase();
|
|
380
|
+
|
|
381
|
+
if (this.containsKeywords(contextLower, ['success', 'happy', 'great', 'excellent', '成功', '素晴らしい', '優秀'])) {
|
|
382
|
+
return 'positive';
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (this.containsKeywords(contextLower, ['action', 'energy', 'dynamic', 'power', 'アクション', 'エネルギー', 'ダイナミック'])) {
|
|
386
|
+
return 'dynamic';
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return 'neutral';
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
detectAction(context) {
|
|
393
|
+
const contextLower = context.toLowerCase();
|
|
394
|
+
|
|
395
|
+
if (this.containsKeywords(contextLower, ['show', 'display', 'present', '表示', '示す'])) {
|
|
396
|
+
return 'showing';
|
|
397
|
+
} else if (this.containsKeywords(contextLower, ['work', 'operate', 'use', '作業', '操作', '使用'])) {
|
|
398
|
+
return 'working';
|
|
399
|
+
} else if (this.containsKeywords(contextLower, ['enjoy', 'experience', 'taste', '楽しむ', '体験', '味わう'])) {
|
|
400
|
+
return 'enjoying';
|
|
401
|
+
} else if (this.containsKeywords(contextLower, ['create', 'build', 'develop', '作成', '構築', '開発'])) {
|
|
402
|
+
return 'creating';
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
detectSubject(context, imageType) {
|
|
409
|
+
const lang = this.config.language;
|
|
410
|
+
const vocab = this.vocabulary[lang];
|
|
411
|
+
|
|
412
|
+
const typeVocab = vocab.types[imageType] || vocab.types.object;
|
|
413
|
+
return this.selectVocabularyWord(typeVocab, 'first') || (lang === 'ja' ? '画像' : 'image');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
extractBrandInfo(context, src) {
|
|
417
|
+
const brandPatterns = [
|
|
418
|
+
/company[^>]*>([^<]+)/i,
|
|
419
|
+
/brand[^>]*>([^<]+)/i,
|
|
420
|
+
/<title[^>]*>([^<]+)/i,
|
|
421
|
+
/alt\s*=\s*["']([^"']*logo[^"']*)["']/i
|
|
422
|
+
];
|
|
423
|
+
|
|
424
|
+
for (const pattern of brandPatterns) {
|
|
425
|
+
const match = context.match(pattern);
|
|
426
|
+
if (match) {
|
|
427
|
+
return { name: match[1].trim().replace(/\s*logo\s*/i, '') };
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return { name: null };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
extractTechnicalInfo(context, src) {
|
|
435
|
+
const contextLower = context.toLowerCase();
|
|
436
|
+
const srcLower = (src || '').toLowerCase();
|
|
437
|
+
|
|
438
|
+
let type = null;
|
|
439
|
+
let data = null;
|
|
440
|
+
let trend = null;
|
|
441
|
+
|
|
442
|
+
if (this.containsKeywords(srcLower + ' ' + contextLower, ['chart', 'graph', 'グラフ', 'チャート'])) {
|
|
443
|
+
if (this.containsKeywords(contextLower, ['bar', 'column', '棒'])) {
|
|
444
|
+
type = this.config.language === 'ja' ? '棒グラフ' : 'Bar chart';
|
|
445
|
+
} else if (this.containsKeywords(contextLower, ['pie', '円'])) {
|
|
446
|
+
type = this.config.language === 'ja' ? '円グラフ' : 'Pie chart';
|
|
447
|
+
} else if (this.containsKeywords(contextLower, ['line', '線'])) {
|
|
448
|
+
type = this.config.language === 'ja' ? '線グラフ' : 'Line chart';
|
|
449
|
+
} else {
|
|
450
|
+
type = this.config.language === 'ja' ? 'グラフ' : 'Chart';
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const numberPattern = /(\d+(?:\.\d+)?)\s*%?/g;
|
|
455
|
+
const numbers = contextLower.match(numberPattern);
|
|
456
|
+
if (numbers && numbers.length > 0) {
|
|
457
|
+
data = numbers.slice(0, 3).join(', ');
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (this.containsKeywords(contextLower, ['increase', 'rise', 'up', '増加', '上昇', '向上'])) {
|
|
461
|
+
trend = this.config.language === 'ja' ? '増加傾向' : 'increasing trend';
|
|
462
|
+
} else if (this.containsKeywords(contextLower, ['decrease', 'fall', 'down', '減少', '下降', '低下'])) {
|
|
463
|
+
trend = this.config.language === 'ja' ? '減少傾向' : 'decreasing trend';
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return { type, data, trend };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
containsKeywords(text, keywords) {
|
|
470
|
+
return keywords.some(keyword => text.includes(keyword.toLowerCase()));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
selectVocabularyWord(words, strategy = 'random') {
|
|
474
|
+
if (!words || words.length === 0) return null;
|
|
475
|
+
|
|
476
|
+
switch (strategy) {
|
|
477
|
+
case 'first':
|
|
478
|
+
return words[0];
|
|
479
|
+
case 'random':
|
|
480
|
+
return words[Math.floor(Math.random() * words.length)];
|
|
481
|
+
case 'shortest':
|
|
482
|
+
return words.reduce((shortest, word) =>
|
|
483
|
+
word.length < shortest.length ? word : shortest
|
|
484
|
+
);
|
|
485
|
+
default:
|
|
486
|
+
return words[0];
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
combineAltParts(parts) {
|
|
491
|
+
const lang = this.config.language;
|
|
492
|
+
const validParts = parts.filter(part => part && part.trim());
|
|
493
|
+
|
|
494
|
+
if (validParts.length === 0) return null;
|
|
495
|
+
|
|
496
|
+
if (lang === 'ja') {
|
|
497
|
+
return validParts.join('');
|
|
498
|
+
} else {
|
|
499
|
+
return validParts.join(' ');
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
generateBasicAlt(analysis) {
|
|
504
|
+
const { imageType, src } = analysis;
|
|
505
|
+
const lang = this.config.language;
|
|
506
|
+
const vocab = this.vocabulary[lang];
|
|
507
|
+
|
|
508
|
+
const typeWords = vocab.types[imageType] || vocab.types.object;
|
|
509
|
+
return this.selectVocabularyWord(typeWords, 'first') || (lang === 'ja' ? '画像' : 'image');
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
generateFallbackAlt(analysis) {
|
|
513
|
+
const { src } = analysis;
|
|
514
|
+
const lang = this.config.language;
|
|
515
|
+
|
|
516
|
+
if (src) {
|
|
517
|
+
const filename = src.split('/').pop().split('.')[0];
|
|
518
|
+
const cleaned = filename.replace(/[-_]/g, ' ').trim();
|
|
519
|
+
|
|
520
|
+
if (cleaned && cleaned.length > 0) {
|
|
521
|
+
return lang === 'ja' ?
|
|
522
|
+
cleaned.replace(/\b\w/g, l => l.toUpperCase()) :
|
|
523
|
+
cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return lang === 'ja' ? '画像' : 'Image';
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
enhanceWithVocabulary(text, imageType) {
|
|
531
|
+
const lang = this.config.language;
|
|
532
|
+
const vocab = this.vocabulary[lang];
|
|
533
|
+
|
|
534
|
+
const typeWords = vocab.types[imageType];
|
|
535
|
+
if (typeWords && typeWords.length > 0) {
|
|
536
|
+
const typeWord = this.selectVocabularyWord(typeWords, 'random');
|
|
537
|
+
|
|
538
|
+
return lang === 'ja' ?
|
|
539
|
+
`${typeWord}:${text}` :
|
|
540
|
+
`${typeWord}: ${text}`;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return text;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
createLinkAlt(linkText, imageType) {
|
|
547
|
+
const lang = this.config.language;
|
|
548
|
+
|
|
549
|
+
return lang === 'ja' ?
|
|
550
|
+
`${linkText}へのリンク` :
|
|
551
|
+
`Link to ${linkText}`;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
createHeadingBasedAlt(heading, imageType) {
|
|
555
|
+
const lang = this.config.language;
|
|
556
|
+
const vocab = this.vocabulary[lang];
|
|
557
|
+
|
|
558
|
+
const typeWords = vocab.types[imageType] || [];
|
|
559
|
+
const typeWord = this.selectVocabularyWord(typeWords, 'first');
|
|
560
|
+
|
|
561
|
+
if (typeWord) {
|
|
562
|
+
return lang === 'ja' ?
|
|
563
|
+
`${heading}の${typeWord}` :
|
|
564
|
+
`${typeWord} of ${heading}`;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return heading;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
createTextBasedAlt(text, imageType) {
|
|
571
|
+
const words = text.split(/\s+/).filter(word => word.length > 2);
|
|
572
|
+
const keyWords = words.slice(0, 3).join(' ');
|
|
573
|
+
|
|
574
|
+
const lang = this.config.language;
|
|
575
|
+
const vocab = this.vocabulary[lang];
|
|
576
|
+
|
|
577
|
+
const typeWords = vocab.types[imageType] || [];
|
|
578
|
+
const typeWord = this.selectVocabularyWord(typeWords, 'first');
|
|
579
|
+
|
|
580
|
+
if (typeWord && keyWords) {
|
|
581
|
+
return lang === 'ja' ?
|
|
582
|
+
`${keyWords}の${typeWord}` :
|
|
583
|
+
`${typeWord} showing ${keyWords}`;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return keyWords || (lang === 'ja' ? '画像' : 'Image');
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
extractMainSubject(context) {
|
|
590
|
+
const sentences = context.split(/[.!?。!?]/);
|
|
591
|
+
const firstSentence = sentences[0];
|
|
592
|
+
|
|
593
|
+
if (firstSentence) {
|
|
594
|
+
const words = firstSentence.split(/\s+/);
|
|
595
|
+
return words.slice(0, 3).join(' ');
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
extractDescription(context) {
|
|
602
|
+
const descriptiveWords = context.match(/\b(beautiful|amazing|professional|modern|elegant|美しい|素晴らしい|プロフェッショナル|モダン|エレガント)\b/gi);
|
|
603
|
+
|
|
604
|
+
return descriptiveWords ? descriptiveWords[0] : null;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
detectContextType(context) {
|
|
608
|
+
const contextLower = context.toLowerCase();
|
|
609
|
+
|
|
610
|
+
if (this.containsKeywords(contextLower, ['business', 'company', 'corporate', 'ビジネス', '企業', '会社'])) {
|
|
611
|
+
return 'business';
|
|
612
|
+
} else if (this.containsKeywords(contextLower, ['education', 'learning', 'school', '教育', '学習', '学校'])) {
|
|
613
|
+
return 'education';
|
|
614
|
+
} else if (this.containsKeywords(contextLower, ['technology', 'tech', 'digital', '技術', 'テクノロジー', 'デジタル'])) {
|
|
615
|
+
return 'technology';
|
|
616
|
+
} else if (this.containsKeywords(contextLower, ['lifestyle', 'personal', 'daily', 'ライフスタイル', '個人', '日常'])) {
|
|
617
|
+
return 'lifestyle';
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
findNearbyHeading(context) {
|
|
624
|
+
const headingRegex = /<h[1-6][^>]*>([^<]+)<\/h[1-6]>/gi;
|
|
625
|
+
const match = headingRegex.exec(context);
|
|
626
|
+
return match ? match[1].trim() : null;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
extractSurroundingText(context) {
|
|
630
|
+
const textOnly = context.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
631
|
+
const words = textOnly.split(' ');
|
|
632
|
+
|
|
633
|
+
const meaningfulWords = words.filter(word =>
|
|
634
|
+
word.length > 2 && !/^\d+$/.test(word) && !/^[^\w]+$/.test(word)
|
|
635
|
+
).slice(0, 8);
|
|
636
|
+
|
|
637
|
+
return meaningfulWords.join(' ');
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
findListContext(context) {
|
|
641
|
+
const listItemRegex = /<li[^>]*>([^<]+)<\/li>/gi;
|
|
642
|
+
const match = listItemRegex.exec(context);
|
|
643
|
+
return match ? match[1].trim() : null;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
findTableContext(context) {
|
|
647
|
+
const tableCellRegex = /<t[hd][^>]*>([^<]+)<\/t[hd]>/gi;
|
|
648
|
+
const match = tableCellRegex.exec(context);
|
|
649
|
+
return match ? match[1].trim() : null;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
extractLinkText(linkTag) {
|
|
653
|
+
const textMatch = linkTag.match(/>([^<]+)</);
|
|
654
|
+
return textMatch ? textMatch[1].trim() : null;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Enhanced Alt Attribute Checker
|
|
660
|
+
* Cải tiến tính năng kiểm tra alt attribute đa dạng và toàn diện hơn
|
|
661
|
+
*/
|
|
662
|
+
class EnhancedAltChecker {
|
|
663
|
+
constructor(config = {}) {
|
|
664
|
+
this.config = {
|
|
665
|
+
language: config.language || 'ja',
|
|
666
|
+
strictMode: config.strictMode || false,
|
|
667
|
+
checkDecorative: config.checkDecorative || true,
|
|
668
|
+
checkInformative: config.checkInformative || true,
|
|
669
|
+
checkComplex: config.checkComplex || true,
|
|
670
|
+
maxAltLength: config.maxAltLength || 125,
|
|
671
|
+
minAltLength: config.minAltLength || 3,
|
|
672
|
+
...config
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
analyzeAltAttributes(content) {
|
|
677
|
+
const issues = [];
|
|
678
|
+
const imgRegex = /<img[^>]*>/gi;
|
|
679
|
+
const imgTags = content.match(imgRegex) || [];
|
|
680
|
+
|
|
681
|
+
imgTags.forEach((imgTag, index) => {
|
|
682
|
+
const analysis = this.analyzeImageContext(imgTag, content, index);
|
|
683
|
+
const altIssues = this.checkAltQuality(imgTag, analysis);
|
|
684
|
+
|
|
685
|
+
if (altIssues.length > 0) {
|
|
686
|
+
issues.push({
|
|
687
|
+
imageIndex: index + 1,
|
|
688
|
+
imgTag: imgTag,
|
|
689
|
+
src: analysis.src,
|
|
690
|
+
context: analysis.context,
|
|
691
|
+
issues: altIssues,
|
|
692
|
+
recommendations: this.generateRecommendations(imgTag, analysis)
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
return issues;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
analyzeImageContext(imgTag, htmlContent, imgIndex) {
|
|
701
|
+
const src = this.extractAttribute(imgTag, 'src');
|
|
702
|
+
const alt = this.extractAttribute(imgTag, 'alt');
|
|
703
|
+
const title = this.extractAttribute(imgTag, 'title');
|
|
704
|
+
const ariaLabel = this.extractAttribute(imgTag, 'aria-label');
|
|
705
|
+
const role = this.extractAttribute(imgTag, 'role');
|
|
706
|
+
|
|
707
|
+
const position = this.findImagePosition(imgTag, htmlContent, imgIndex);
|
|
708
|
+
const surroundingContext = this.extractSurroundingContext(htmlContent, position, 1000);
|
|
709
|
+
|
|
710
|
+
const imageType = this.classifyImageType(imgTag, surroundingContext, src);
|
|
711
|
+
|
|
712
|
+
const structuralContext = this.analyzeStructuralContext(surroundingContext, imgTag);
|
|
713
|
+
|
|
714
|
+
return {
|
|
715
|
+
src,
|
|
716
|
+
alt,
|
|
717
|
+
title,
|
|
718
|
+
ariaLabel,
|
|
719
|
+
role,
|
|
720
|
+
imageType,
|
|
721
|
+
context: surroundingContext,
|
|
722
|
+
structural: structuralContext,
|
|
723
|
+
position
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
checkAltQuality(imgTag, analysis) {
|
|
728
|
+
const issues = [];
|
|
729
|
+
const { alt, imageType, src } = analysis;
|
|
730
|
+
|
|
731
|
+
if (!this.hasAttribute(imgTag, 'alt')) {
|
|
732
|
+
issues.push({
|
|
733
|
+
type: 'MISSING_ALT',
|
|
734
|
+
severity: 'ERROR',
|
|
735
|
+
message: 'Thiếu thuộc tính alt',
|
|
736
|
+
description: 'Tất cả hình ảnh phải có thuộc tính alt'
|
|
737
|
+
});
|
|
738
|
+
return issues;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (alt === '') {
|
|
742
|
+
if (imageType === 'decorative') {
|
|
743
|
+
return issues;
|
|
744
|
+
} else {
|
|
745
|
+
issues.push({
|
|
746
|
+
type: 'EMPTY_ALT',
|
|
747
|
+
severity: 'ERROR',
|
|
748
|
+
message: 'Alt text rỗng cho hình ảnh có nội dung',
|
|
749
|
+
description: 'Hình ảnh có nội dung cần alt text mô tả'
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (alt && alt.length > this.config.maxAltLength) {
|
|
755
|
+
issues.push({
|
|
756
|
+
type: 'ALT_TOO_LONG',
|
|
757
|
+
severity: 'WARNING',
|
|
758
|
+
message: `Alt text quá dài (${alt.length} ký tự)`,
|
|
759
|
+
description: `Nên giới hạn dưới ${this.config.maxAltLength} ký tự`
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
if (alt && alt.length < this.config.minAltLength && imageType !== 'decorative') {
|
|
764
|
+
issues.push({
|
|
765
|
+
type: 'ALT_TOO_SHORT',
|
|
766
|
+
severity: 'WARNING',
|
|
767
|
+
message: `Alt text quá ngắn (${alt.length} ký tự)`,
|
|
768
|
+
description: 'Alt text nên mô tả đầy đủ nội dung hình ảnh'
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const contentIssues = this.checkAltContent(alt, src, imageType);
|
|
773
|
+
issues.push(...contentIssues);
|
|
774
|
+
|
|
775
|
+
const consistencyIssues = this.checkAttributeConsistency(analysis);
|
|
776
|
+
issues.push(...consistencyIssues);
|
|
777
|
+
|
|
778
|
+
const typeSpecificIssues = this.checkTypeSpecificRequirements(analysis);
|
|
779
|
+
issues.push(...typeSpecificIssues);
|
|
780
|
+
|
|
781
|
+
return issues;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
classifyImageType(imgTag, context, src) {
|
|
785
|
+
const srcLower = (src || '').toLowerCase();
|
|
786
|
+
const contextLower = context.toLowerCase();
|
|
787
|
+
|
|
788
|
+
if (this.isDecorativeImage(imgTag, context, src)) {
|
|
789
|
+
return 'decorative';
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if (this.isDataVisualization(srcLower, contextLower)) {
|
|
793
|
+
return 'data-visualization';
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (this.isComplexImage(srcLower, contextLower)) {
|
|
797
|
+
return 'complex';
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if (this.isLogo(srcLower, contextLower)) {
|
|
801
|
+
return 'logo';
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (this.isFunctionalIcon(imgTag, context, srcLower)) {
|
|
805
|
+
return 'functional-icon';
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (this.isContentImage(contextLower)) {
|
|
809
|
+
return 'content';
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
return 'informative';
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
checkAltContent(alt, src, imageType) {
|
|
816
|
+
const issues = [];
|
|
817
|
+
|
|
818
|
+
if (!alt) return issues;
|
|
819
|
+
|
|
820
|
+
const altLower = alt.toLowerCase();
|
|
821
|
+
const srcLower = (src || '').toLowerCase();
|
|
822
|
+
|
|
823
|
+
const forbiddenWords = [
|
|
824
|
+
'image', 'picture', 'photo', 'graphic', 'img',
|
|
825
|
+
'画像', '写真', 'イメージ', '図', '図表'
|
|
826
|
+
];
|
|
827
|
+
|
|
828
|
+
const foundForbidden = forbiddenWords.find(word => altLower.includes(word));
|
|
829
|
+
if (foundForbidden) {
|
|
830
|
+
issues.push({
|
|
831
|
+
type: 'REDUNDANT_WORDS',
|
|
832
|
+
severity: 'WARNING',
|
|
833
|
+
message: `Alt text chứa từ thừa: "${foundForbidden}"`,
|
|
834
|
+
description: 'Không cần nói "hình ảnh" trong alt text'
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (src) {
|
|
839
|
+
const filename = src.split('/').pop().split('.')[0];
|
|
840
|
+
if (altLower.includes(filename.toLowerCase())) {
|
|
841
|
+
issues.push({
|
|
842
|
+
type: 'FILENAME_IN_ALT',
|
|
843
|
+
severity: 'WARNING',
|
|
844
|
+
message: 'Alt text chứa tên file',
|
|
845
|
+
description: 'Nên mô tả nội dung thay vì tên file'
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const genericTexts = [
|
|
851
|
+
'click here', 'read more', 'learn more', 'see more',
|
|
852
|
+
'ここをクリック', '詳細', 'もっと見る'
|
|
853
|
+
];
|
|
854
|
+
|
|
855
|
+
const foundGeneric = genericTexts.find(text => altLower.includes(text));
|
|
856
|
+
if (foundGeneric) {
|
|
857
|
+
issues.push({
|
|
858
|
+
type: 'GENERIC_ALT',
|
|
859
|
+
severity: 'ERROR',
|
|
860
|
+
message: `Alt text quá chung chung: "${foundGeneric}"`,
|
|
861
|
+
description: 'Nên mô tả cụ thể nội dung hình ảnh'
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
if (imageType === 'data-visualization' && !this.hasDataDescription(alt)) {
|
|
866
|
+
issues.push({
|
|
867
|
+
type: 'MISSING_DATA_DESCRIPTION',
|
|
868
|
+
severity: 'ERROR',
|
|
869
|
+
message: 'Biểu đồ thiếu mô tả dữ liệu',
|
|
870
|
+
description: 'Biểu đồ cần mô tả xu hướng và dữ liệu chính'
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
return issues;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
generateRecommendations(imgTag, analysis) {
|
|
878
|
+
const recommendations = [];
|
|
879
|
+
const { imageType, context, src, alt } = analysis;
|
|
880
|
+
|
|
881
|
+
switch (imageType) {
|
|
882
|
+
case 'decorative':
|
|
883
|
+
recommendations.push({
|
|
884
|
+
type: 'DECORATIVE',
|
|
885
|
+
suggestion: 'alt=""',
|
|
886
|
+
reason: 'Hình trang trí nên có alt rỗng'
|
|
887
|
+
});
|
|
888
|
+
break;
|
|
889
|
+
|
|
890
|
+
case 'logo':
|
|
891
|
+
const brandName = this.extractBrandName(context, src);
|
|
892
|
+
recommendations.push({
|
|
893
|
+
type: 'LOGO',
|
|
894
|
+
suggestion: brandName ? `alt="${brandName} logo"` : 'alt="Company logo"',
|
|
895
|
+
reason: 'Logo nên bao gồm tên thương hiệu'
|
|
896
|
+
});
|
|
897
|
+
break;
|
|
898
|
+
|
|
899
|
+
case 'functional-icon':
|
|
900
|
+
const action = this.extractIconAction(context, imgTag);
|
|
901
|
+
recommendations.push({
|
|
902
|
+
type: 'FUNCTIONAL',
|
|
903
|
+
suggestion: action ? `alt="${action}"` : 'alt="[Mô tả chức năng]"',
|
|
904
|
+
reason: 'Icon chức năng nên mô tả hành động'
|
|
905
|
+
});
|
|
906
|
+
break;
|
|
907
|
+
|
|
908
|
+
case 'data-visualization':
|
|
909
|
+
recommendations.push({
|
|
910
|
+
type: 'DATA_VIZ',
|
|
911
|
+
suggestion: 'alt="[Loại biểu đồ]: [Xu hướng chính] [Dữ liệu quan trọng]"',
|
|
912
|
+
reason: 'Biểu đồ cần mô tả loại, xu hướng và dữ liệu chính'
|
|
913
|
+
});
|
|
914
|
+
break;
|
|
915
|
+
|
|
916
|
+
case 'complex':
|
|
917
|
+
recommendations.push({
|
|
918
|
+
type: 'COMPLEX',
|
|
919
|
+
suggestion: 'alt="[Mô tả ngắn]" + longdesc hoặc mô tả chi tiết bên dưới',
|
|
920
|
+
reason: 'Hình phức tạp cần mô tả ngắn trong alt và mô tả dài riêng'
|
|
921
|
+
});
|
|
922
|
+
break;
|
|
923
|
+
|
|
924
|
+
default:
|
|
925
|
+
const contextualAlt = this.generateContextualAlt(analysis);
|
|
926
|
+
recommendations.push({
|
|
927
|
+
type: 'CONTEXTUAL',
|
|
928
|
+
suggestion: `alt="${contextualAlt}"`,
|
|
929
|
+
reason: 'Mô tả dựa trên ngữ cảnh xung quanh'
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
return recommendations;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
generateContextualAlt(analysis) {
|
|
937
|
+
const { context, src, structural } = analysis;
|
|
938
|
+
|
|
939
|
+
const nearbyHeading = this.findNearbyHeading(context);
|
|
940
|
+
if (nearbyHeading) {
|
|
941
|
+
return nearbyHeading;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
if (structural.parentLink) {
|
|
945
|
+
const linkText = this.extractLinkText(structural.parentLink);
|
|
946
|
+
if (linkText) {
|
|
947
|
+
return linkText;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
if (structural.figcaption) {
|
|
952
|
+
return structural.figcaption;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const surroundingText = this.extractSurroundingText(context);
|
|
956
|
+
if (surroundingText) {
|
|
957
|
+
return surroundingText;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
return this.generateFallbackAlt(src);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Helper methods
|
|
964
|
+
isDecorativeImage(imgTag, context, src) {
|
|
965
|
+
const decorativeIndicators = [
|
|
966
|
+
'decoration', 'border', 'spacer', 'divider',
|
|
967
|
+
'background', 'texture', 'pattern'
|
|
968
|
+
];
|
|
969
|
+
|
|
970
|
+
const srcLower = (src || '').toLowerCase();
|
|
971
|
+
return decorativeIndicators.some(indicator => srcLower.includes(indicator));
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
isDataVisualization(src, context) {
|
|
975
|
+
const dataIndicators = [
|
|
976
|
+
'chart', 'graph', 'plot', 'diagram', 'infographic',
|
|
977
|
+
'グラフ', '図表', 'チャート'
|
|
978
|
+
];
|
|
979
|
+
|
|
980
|
+
return dataIndicators.some(indicator =>
|
|
981
|
+
src.includes(indicator) || context.includes(indicator)
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
isComplexImage(src, context) {
|
|
986
|
+
const complexIndicators = [
|
|
987
|
+
'flowchart', 'timeline', 'map', 'blueprint', 'schematic',
|
|
988
|
+
'フローチャート', '地図', '設計図'
|
|
989
|
+
];
|
|
990
|
+
|
|
991
|
+
return complexIndicators.some(indicator =>
|
|
992
|
+
src.includes(indicator) || context.includes(indicator)
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
isLogo(src, context) {
|
|
997
|
+
const logoIndicators = ['logo', 'brand', 'ロゴ', 'ブランド'];
|
|
998
|
+
return logoIndicators.some(indicator =>
|
|
999
|
+
src.includes(indicator) || context.includes(indicator)
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
isFunctionalIcon(imgTag, context, src) {
|
|
1004
|
+
const iconIndicators = ['icon', 'btn', 'button', 'アイコン', 'ボタン'];
|
|
1005
|
+
const hasClickHandler = /onclick|href/i.test(context);
|
|
1006
|
+
|
|
1007
|
+
return (iconIndicators.some(indicator => src.includes(indicator)) || hasClickHandler);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
isContentImage(context) {
|
|
1011
|
+
const contentIndicators = [
|
|
1012
|
+
'article', 'content', 'story', 'news',
|
|
1013
|
+
'記事', 'コンテンツ', 'ニュース'
|
|
1014
|
+
];
|
|
1015
|
+
|
|
1016
|
+
return contentIndicators.some(indicator => context.includes(indicator));
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
extractAttribute(imgTag, attributeName) {
|
|
1020
|
+
const regex = new RegExp(`${attributeName}\\s*=\\s*["']([^"']*)["']`, 'i');
|
|
1021
|
+
const match = imgTag.match(regex);
|
|
1022
|
+
return match ? match[1] : null;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
hasAttribute(imgTag, attributeName) {
|
|
1026
|
+
const regex = new RegExp(`${attributeName}\\s*=`, 'i');
|
|
1027
|
+
return regex.test(imgTag);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
findImagePosition(imgTag, htmlContent, imgIndex) {
|
|
1031
|
+
const imgRegex = /<img[^>]*>/gi;
|
|
1032
|
+
let match;
|
|
1033
|
+
let currentIndex = 0;
|
|
1034
|
+
|
|
1035
|
+
while ((match = imgRegex.exec(htmlContent)) !== null) {
|
|
1036
|
+
if (currentIndex === imgIndex) {
|
|
1037
|
+
return match.index;
|
|
1038
|
+
}
|
|
1039
|
+
currentIndex++;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
return -1;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
extractSurroundingContext(htmlContent, position, range) {
|
|
1046
|
+
if (position === -1) return '';
|
|
1047
|
+
|
|
1048
|
+
const start = Math.max(0, position - range);
|
|
1049
|
+
const end = Math.min(htmlContent.length, position + range);
|
|
1050
|
+
|
|
1051
|
+
return htmlContent.substring(start, end);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
analyzeStructuralContext(context, imgTag) {
|
|
1055
|
+
return {
|
|
1056
|
+
parentLink: this.findParentElement(context, imgTag, 'a'),
|
|
1057
|
+
parentFigure: this.findParentElement(context, imgTag, 'figure'),
|
|
1058
|
+
figcaption: this.findSiblingElement(context, imgTag, 'figcaption'),
|
|
1059
|
+
parentButton: this.findParentElement(context, imgTag, 'button')
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
findParentElement(context, imgTag, tagName) {
|
|
1064
|
+
const imgIndex = context.indexOf(imgTag);
|
|
1065
|
+
if (imgIndex === -1) return null;
|
|
1066
|
+
|
|
1067
|
+
const beforeImg = context.substring(0, imgIndex);
|
|
1068
|
+
const openTagRegex = new RegExp(`<${tagName}[^>]*>`, 'gi');
|
|
1069
|
+
const closeTagRegex = new RegExp(`</${tagName}>`, 'gi');
|
|
1070
|
+
|
|
1071
|
+
let openTags = 0;
|
|
1072
|
+
let lastOpenMatch = null;
|
|
1073
|
+
|
|
1074
|
+
let match;
|
|
1075
|
+
while ((match = openTagRegex.exec(beforeImg)) !== null) {
|
|
1076
|
+
lastOpenMatch = match;
|
|
1077
|
+
openTags++;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
while ((match = closeTagRegex.exec(beforeImg)) !== null) {
|
|
1081
|
+
openTags--;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
return openTags > 0 ? lastOpenMatch[0] : null;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
findSiblingElement(context, imgTag, tagName) {
|
|
1088
|
+
const imgIndex = context.indexOf(imgTag);
|
|
1089
|
+
if (imgIndex === -1) return null;
|
|
1090
|
+
|
|
1091
|
+
const afterImg = context.substring(imgIndex + imgTag.length);
|
|
1092
|
+
const siblingRegex = new RegExp(`<${tagName}[^>]*>([^<]*)</${tagName}>`, 'i');
|
|
1093
|
+
const match = afterImg.match(siblingRegex);
|
|
1094
|
+
|
|
1095
|
+
return match ? match[1].trim() : null;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
checkAttributeConsistency(analysis) {
|
|
1099
|
+
const issues = [];
|
|
1100
|
+
const { alt, title, ariaLabel } = analysis;
|
|
1101
|
+
|
|
1102
|
+
if (alt && ariaLabel && alt !== ariaLabel) {
|
|
1103
|
+
issues.push({
|
|
1104
|
+
type: 'INCONSISTENT_LABELS',
|
|
1105
|
+
severity: 'WARNING',
|
|
1106
|
+
message: 'Alt text và aria-label không nhất quán',
|
|
1107
|
+
description: 'Alt và aria-label nên có nội dung giống nhau'
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
if (title && alt && title === alt) {
|
|
1112
|
+
issues.push({
|
|
1113
|
+
type: 'REDUNDANT_TITLE',
|
|
1114
|
+
severity: 'INFO',
|
|
1115
|
+
message: 'Title attribute trùng với alt text',
|
|
1116
|
+
description: 'Title có thể bỏ đi để tránh lặp lại'
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
return issues;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
checkTypeSpecificRequirements(analysis) {
|
|
1124
|
+
const issues = [];
|
|
1125
|
+
const { imageType, alt, structural } = analysis;
|
|
1126
|
+
|
|
1127
|
+
switch (imageType) {
|
|
1128
|
+
case 'functional-icon':
|
|
1129
|
+
if (structural.parentLink && !alt) {
|
|
1130
|
+
issues.push({
|
|
1131
|
+
type: 'FUNCTIONAL_MISSING_ALT',
|
|
1132
|
+
severity: 'ERROR',
|
|
1133
|
+
message: 'Icon chức năng trong link thiếu alt text',
|
|
1134
|
+
description: 'Icon có chức năng phải có alt mô tả hành động'
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
break;
|
|
1138
|
+
|
|
1139
|
+
case 'logo':
|
|
1140
|
+
if (alt && !alt.toLowerCase().includes('logo')) {
|
|
1141
|
+
issues.push({
|
|
1142
|
+
type: 'LOGO_MISSING_CONTEXT',
|
|
1143
|
+
severity: 'WARNING',
|
|
1144
|
+
message: 'Logo thiếu từ khóa "logo" trong alt text',
|
|
1145
|
+
description: 'Logo nên bao gồm từ "logo" để rõ ràng'
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
break;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
return issues;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
hasDataDescription(alt) {
|
|
1155
|
+
const dataKeywords = [
|
|
1156
|
+
'increase', 'decrease', 'trend', 'percent', '%',
|
|
1157
|
+
'増加', '減少', 'トレンド', 'パーセント'
|
|
1158
|
+
];
|
|
1159
|
+
|
|
1160
|
+
return dataKeywords.some(keyword =>
|
|
1161
|
+
alt.toLowerCase().includes(keyword.toLowerCase())
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
findNearbyHeading(context) {
|
|
1166
|
+
const headingRegex = /<h[1-6][^>]*>([^<]+)<\/h[1-6]>/gi;
|
|
1167
|
+
const match = headingRegex.exec(context);
|
|
1168
|
+
return match ? match[1].trim() : null;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
extractLinkText(linkTag) {
|
|
1172
|
+
const textMatch = linkTag.match(/>([^<]+)</);
|
|
1173
|
+
return textMatch ? textMatch[1].trim() : null;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
extractSurroundingText(context) {
|
|
1177
|
+
const textOnly = context.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
1178
|
+
const words = textOnly.split(' ');
|
|
1179
|
+
|
|
1180
|
+
const meaningfulWords = words.filter(word =>
|
|
1181
|
+
word.length > 2 && !/^\d+$/.test(word)
|
|
1182
|
+
).slice(0, 5);
|
|
1183
|
+
|
|
1184
|
+
return meaningfulWords.join(' ');
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
generateFallbackAlt(src) {
|
|
1188
|
+
if (!src) return '画像';
|
|
1189
|
+
|
|
1190
|
+
const filename = src.split('/').pop().split('.')[0];
|
|
1191
|
+
|
|
1192
|
+
return filename
|
|
1193
|
+
.replace(/[-_]/g, ' ')
|
|
1194
|
+
.replace(/\b\w/g, l => l.toUpperCase())
|
|
1195
|
+
.trim() || '画像';
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
extractBrandName(context, src) {
|
|
1199
|
+
const brandPatterns = [
|
|
1200
|
+
/company[^>]*>([^<]+)/i,
|
|
1201
|
+
/brand[^>]*>([^<]+)/i,
|
|
1202
|
+
/logo[^>]*>([^<]+)/i
|
|
1203
|
+
];
|
|
1204
|
+
|
|
1205
|
+
for (const pattern of brandPatterns) {
|
|
1206
|
+
const match = context.match(pattern);
|
|
1207
|
+
if (match) return match[1].trim();
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
return null;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
extractIconAction(context, imgTag) {
|
|
1214
|
+
const actionPatterns = [
|
|
1215
|
+
/title\s*=\s*["']([^"']+)["']/i,
|
|
1216
|
+
/aria-label\s*=\s*["']([^"']+)["']/i,
|
|
1217
|
+
/onclick[^>]*>([^<]+)/i
|
|
1218
|
+
];
|
|
1219
|
+
|
|
1220
|
+
for (const pattern of actionPatterns) {
|
|
1221
|
+
const match = imgTag.match(pattern) || context.match(pattern);
|
|
1222
|
+
if (match) return match[1].trim();
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
return null;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
10
1229
|
class AccessibilityFixer {
|
|
11
1230
|
constructor(config = {}) {
|
|
12
1231
|
this.config = {
|
|
13
1232
|
backupFiles: config.backupFiles === true,
|
|
14
1233
|
language: config.language || 'ja',
|
|
15
1234
|
dryRun: config.dryRun || false,
|
|
1235
|
+
enhancedAltMode: config.enhancedAltMode || false,
|
|
1236
|
+
altCreativity: config.altCreativity || 'balanced', // conservative, balanced, creative
|
|
1237
|
+
includeEmotions: config.includeEmotions || false,
|
|
1238
|
+
strictAltChecking: config.strictAltChecking || false,
|
|
16
1239
|
...config
|
|
17
1240
|
};
|
|
1241
|
+
|
|
1242
|
+
// Initialize enhanced alt tools
|
|
1243
|
+
this.enhancedAltChecker = new EnhancedAltChecker({
|
|
1244
|
+
language: this.config.language,
|
|
1245
|
+
strictMode: this.config.strictAltChecking,
|
|
1246
|
+
checkDecorative: true,
|
|
1247
|
+
checkInformative: true,
|
|
1248
|
+
checkComplex: true
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
this.enhancedAltGenerator = new EnhancedAltGenerator({
|
|
1252
|
+
language: this.config.language,
|
|
1253
|
+
creativity: this.config.altCreativity,
|
|
1254
|
+
includeEmotions: this.config.includeEmotions,
|
|
1255
|
+
includeBrandContext: true
|
|
1256
|
+
});
|
|
18
1257
|
}
|
|
19
1258
|
|
|
20
1259
|
async fixHtmlLang(directory = '.') {
|
|
@@ -57,18 +1296,50 @@ class AccessibilityFixer {
|
|
|
57
1296
|
const htmlFiles = await this.findHtmlFiles(directory);
|
|
58
1297
|
const results = [];
|
|
59
1298
|
let totalIssuesFound = 0;
|
|
1299
|
+
let enhancedIssues = []; // Declare here to avoid scope issues
|
|
60
1300
|
|
|
61
1301
|
for (const file of htmlFiles) {
|
|
62
1302
|
try {
|
|
63
1303
|
const content = await fs.readFile(file, 'utf8');
|
|
64
|
-
const issues = this.analyzeAltAttributes(content);
|
|
65
1304
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
1305
|
+
// Use enhanced alt checker if enabled
|
|
1306
|
+
if (this.config.enhancedAltMode) {
|
|
1307
|
+
enhancedIssues = this.enhancedAltChecker.analyzeAltAttributes(content);
|
|
1308
|
+
|
|
1309
|
+
if (enhancedIssues.length > 0) {
|
|
1310
|
+
console.log(chalk.cyan(`\n📁 ${file}:`));
|
|
1311
|
+
enhancedIssues.forEach(issue => {
|
|
1312
|
+
console.log(chalk.yellow(` 🔍 Image ${issue.imageIndex} (${issue.src}):`));
|
|
1313
|
+
issue.issues.forEach(subIssue => {
|
|
1314
|
+
const icon = subIssue.severity === 'ERROR' ? '❌' :
|
|
1315
|
+
subIssue.severity === 'WARNING' ? '⚠️' : 'ℹ️';
|
|
1316
|
+
console.log(chalk.yellow(` ${icon} ${subIssue.message}`));
|
|
1317
|
+
console.log(chalk.gray(` ${subIssue.description}`));
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
// Show recommendations
|
|
1321
|
+
if (issue.recommendations.length > 0) {
|
|
1322
|
+
console.log(chalk.blue(` 💡 Recommendations:`));
|
|
1323
|
+
issue.recommendations.forEach(rec => {
|
|
1324
|
+
console.log(chalk.blue(` ${rec.suggestion}`));
|
|
1325
|
+
console.log(chalk.gray(` ${rec.reason}`));
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
totalIssuesFound += issue.issues.length;
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
} else {
|
|
1332
|
+
// Use original analysis
|
|
1333
|
+
const issues = this.analyzeAltAttributes(content);
|
|
1334
|
+
|
|
1335
|
+
if (issues.length > 0) {
|
|
1336
|
+
console.log(chalk.cyan(`\n📁 ${file}:`));
|
|
1337
|
+
issues.forEach(issue => {
|
|
1338
|
+
console.log(chalk.yellow(` ${issue.type}: ${issue.description}`));
|
|
1339
|
+
totalIssuesFound++;
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
enhancedIssues = issues; // For consistency in results calculation
|
|
72
1343
|
}
|
|
73
1344
|
|
|
74
1345
|
const fixed = this.fixAltAttributes(content);
|
|
@@ -83,9 +1354,11 @@ class AccessibilityFixer {
|
|
|
83
1354
|
}
|
|
84
1355
|
|
|
85
1356
|
console.log(chalk.green(`✅ Fixed alt attributes in: ${file}`));
|
|
86
|
-
results.push({ file, status: 'fixed', issues:
|
|
1357
|
+
results.push({ file, status: 'fixed', issues: this.config.enhancedAltMode ?
|
|
1358
|
+
enhancedIssues.reduce((sum, ei) => sum + (ei.issues ? ei.issues.length : 1), 0) : enhancedIssues.length });
|
|
87
1359
|
} else {
|
|
88
|
-
results.push({ file, status: 'no-change', issues:
|
|
1360
|
+
results.push({ file, status: 'no-change', issues: this.config.enhancedAltMode ?
|
|
1361
|
+
enhancedIssues.reduce((sum, ei) => sum + (ei.issues ? ei.issues.length : 1), 0) : enhancedIssues.length });
|
|
89
1362
|
}
|
|
90
1363
|
} catch (error) {
|
|
91
1364
|
console.error(chalk.red(`❌ Error processing ${file}: ${error.message}`));
|
|
@@ -94,6 +1367,9 @@ class AccessibilityFixer {
|
|
|
94
1367
|
}
|
|
95
1368
|
|
|
96
1369
|
console.log(chalk.blue(`\n📊 Summary: Found ${totalIssuesFound} alt attribute issues across ${results.length} files`));
|
|
1370
|
+
if (this.config.enhancedAltMode) {
|
|
1371
|
+
console.log(chalk.gray(` 🔍 Enhanced analysis mode: Comprehensive quality checking enabled`));
|
|
1372
|
+
}
|
|
97
1373
|
return results;
|
|
98
1374
|
}
|
|
99
1375
|
|
|
@@ -245,6 +1521,21 @@ class AccessibilityFixer {
|
|
|
245
1521
|
}
|
|
246
1522
|
|
|
247
1523
|
generateAltText(imgTag, htmlContent = '', imgIndex = 0) {
|
|
1524
|
+
// Use enhanced alt generator if enabled
|
|
1525
|
+
if (this.config.enhancedAltMode) {
|
|
1526
|
+
try {
|
|
1527
|
+
const analysis = this.enhancedAltChecker.analyzeImageContext(imgTag, htmlContent, imgIndex);
|
|
1528
|
+
const enhancedAlt = this.enhancedAltGenerator.generateDiverseAltText(imgTag, htmlContent, analysis);
|
|
1529
|
+
|
|
1530
|
+
if (enhancedAlt && enhancedAlt.trim().length > 0) {
|
|
1531
|
+
return enhancedAlt;
|
|
1532
|
+
}
|
|
1533
|
+
} catch (error) {
|
|
1534
|
+
console.warn(chalk.yellow(`⚠️ Enhanced alt generation failed, falling back to basic mode: ${error.message}`));
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// Fallback to original method
|
|
248
1539
|
const src = imgTag.match(/src\s*=\s*["']([^"']+)["']/i);
|
|
249
1540
|
const srcValue = src ? src[1].toLowerCase() : '';
|
|
250
1541
|
|
|
@@ -943,7 +2234,8 @@ class AccessibilityFixer {
|
|
|
943
2234
|
buttons: [],
|
|
944
2235
|
links: [],
|
|
945
2236
|
landmarks: [],
|
|
946
|
-
headings: [] // Analysis only
|
|
2237
|
+
headings: [], // Analysis only
|
|
2238
|
+
brokenLinks: [] // Analysis only
|
|
947
2239
|
};
|
|
948
2240
|
|
|
949
2241
|
try {
|
|
@@ -979,8 +2271,12 @@ class AccessibilityFixer {
|
|
|
979
2271
|
console.log(chalk.yellow('\n📑 Step 8: Heading analysis...'));
|
|
980
2272
|
results.headings = await this.analyzeHeadings(directory);
|
|
981
2273
|
|
|
982
|
-
// Step 9:
|
|
983
|
-
console.log(chalk.yellow('\n
|
|
2274
|
+
// Step 9: Check broken links (no auto-fix)
|
|
2275
|
+
console.log(chalk.yellow('\n🔗 Step 9: Broken links check...'));
|
|
2276
|
+
results.brokenLinks = await this.checkBrokenLinks(directory);
|
|
2277
|
+
|
|
2278
|
+
// Step 10: Cleanup duplicate roles
|
|
2279
|
+
console.log(chalk.yellow('\n🧹 Step 10: Cleanup duplicate roles...'));
|
|
984
2280
|
results.cleanup = await this.cleanupDuplicateRoles(directory);
|
|
985
2281
|
|
|
986
2282
|
// Summary
|
|
@@ -1567,6 +2863,215 @@ class AccessibilityFixer {
|
|
|
1567
2863
|
return fixed;
|
|
1568
2864
|
}
|
|
1569
2865
|
|
|
2866
|
+
// Check for broken links and 404 resources
|
|
2867
|
+
async checkBrokenLinks(directory = '.') {
|
|
2868
|
+
console.log(chalk.blue('🔗 Checking for broken links and 404 resources...'));
|
|
2869
|
+
|
|
2870
|
+
const htmlFiles = await this.findHtmlFiles(directory);
|
|
2871
|
+
const results = [];
|
|
2872
|
+
|
|
2873
|
+
for (const file of htmlFiles) {
|
|
2874
|
+
try {
|
|
2875
|
+
const content = await fs.readFile(file, 'utf8');
|
|
2876
|
+
const issues = await this.analyzeBrokenLinks(content, file);
|
|
2877
|
+
|
|
2878
|
+
if (issues.length > 0) {
|
|
2879
|
+
console.log(chalk.cyan(`\n📁 ${file}:`));
|
|
2880
|
+
issues.forEach(issue => {
|
|
2881
|
+
console.log(chalk.yellow(` ${issue.type}: ${issue.description}`));
|
|
2882
|
+
if (issue.suggestion) {
|
|
2883
|
+
console.log(chalk.gray(` 💡 ${issue.suggestion}`));
|
|
2884
|
+
}
|
|
2885
|
+
});
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
results.push({ file, status: 'analyzed', issues: issues.length, brokenLinks: issues });
|
|
2889
|
+
} catch (error) {
|
|
2890
|
+
console.error(chalk.red(`❌ Error processing ${file}: ${error.message}`));
|
|
2891
|
+
results.push({ file, status: 'error', error: error.message });
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
console.log(chalk.blue(`\n📊 Summary: Analyzed links in ${results.length} files`));
|
|
2896
|
+
console.log(chalk.gray('💡 Broken link issues require manual review and cannot be auto-fixed'));
|
|
2897
|
+
return results;
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
async analyzeBrokenLinks(content, filePath) {
|
|
2901
|
+
const issues = [];
|
|
2902
|
+
const path = require('path');
|
|
2903
|
+
const http = require('http');
|
|
2904
|
+
const https = require('https');
|
|
2905
|
+
const { URL } = require('url');
|
|
2906
|
+
|
|
2907
|
+
// Extract all links and resources
|
|
2908
|
+
const linkPatterns = [
|
|
2909
|
+
// Anchor links
|
|
2910
|
+
{ pattern: /<a[^>]*href\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'Link', element: 'a' },
|
|
2911
|
+
// Images
|
|
2912
|
+
{ pattern: /<img[^>]*src\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'Image', element: 'img' },
|
|
2913
|
+
// CSS links
|
|
2914
|
+
{ pattern: /<link[^>]*href\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'CSS', element: 'link' },
|
|
2915
|
+
// Script sources
|
|
2916
|
+
{ pattern: /<script[^>]*src\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'Script', element: 'script' },
|
|
2917
|
+
// Video sources
|
|
2918
|
+
{ pattern: /<video[^>]*src\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'Video', element: 'video' },
|
|
2919
|
+
// Audio sources
|
|
2920
|
+
{ pattern: /<audio[^>]*src\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'Audio', element: 'audio' }
|
|
2921
|
+
];
|
|
2922
|
+
|
|
2923
|
+
const baseDir = path.dirname(filePath);
|
|
2924
|
+
|
|
2925
|
+
for (const linkPattern of linkPatterns) {
|
|
2926
|
+
let match;
|
|
2927
|
+
while ((match = linkPattern.pattern.exec(content)) !== null) {
|
|
2928
|
+
const url = match[1];
|
|
2929
|
+
const issue = await this.checkSingleLink(url, baseDir, linkPattern.type, linkPattern.element);
|
|
2930
|
+
if (issue) {
|
|
2931
|
+
issues.push(issue);
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
return issues;
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
async checkSingleLink(url, baseDir, resourceType, elementType) {
|
|
2940
|
+
// Skip certain URLs
|
|
2941
|
+
if (this.shouldSkipUrl(url)) {
|
|
2942
|
+
return null;
|
|
2943
|
+
}
|
|
2944
|
+
|
|
2945
|
+
try {
|
|
2946
|
+
if (this.isExternalUrl(url)) {
|
|
2947
|
+
// Check external URLs
|
|
2948
|
+
return await this.checkExternalUrl(url, resourceType, elementType);
|
|
2949
|
+
} else {
|
|
2950
|
+
// Check local files
|
|
2951
|
+
return await this.checkLocalFile(url, baseDir, resourceType, elementType);
|
|
2952
|
+
}
|
|
2953
|
+
} catch (error) {
|
|
2954
|
+
return {
|
|
2955
|
+
type: `🔗 ${resourceType} check error`,
|
|
2956
|
+
description: `Failed to check ${elementType}: ${url}`,
|
|
2957
|
+
suggestion: `Verify the ${resourceType.toLowerCase()} manually: ${error.message}`,
|
|
2958
|
+
url: url,
|
|
2959
|
+
resourceType: resourceType
|
|
2960
|
+
};
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
shouldSkipUrl(url) {
|
|
2965
|
+
const skipPatterns = [
|
|
2966
|
+
/^#/, // Anchor links
|
|
2967
|
+
/^mailto:/, // Email links
|
|
2968
|
+
/^tel:/, // Phone links
|
|
2969
|
+
/^javascript:/, // JavaScript links
|
|
2970
|
+
/^data:/, // Data URLs
|
|
2971
|
+
/^\{\{.*\}\}$/, // Template variables
|
|
2972
|
+
/^\$\{.*\}$/, // Template literals
|
|
2973
|
+
/^<%.*%>$/, // Template tags
|
|
2974
|
+
/^\/\/$/, // Protocol-relative empty
|
|
2975
|
+
/^https?:\/\/localhost/, // Localhost URLs
|
|
2976
|
+
/^https?:\/\/127\.0\.0\.1/, // Local IP
|
|
2977
|
+
/^https?:\/\/0\.0\.0\.0/ // All interfaces IP
|
|
2978
|
+
];
|
|
2979
|
+
|
|
2980
|
+
return skipPatterns.some(pattern => pattern.test(url));
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
isExternalUrl(url) {
|
|
2984
|
+
return /^https?:\/\//.test(url);
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
async checkExternalUrl(url, resourceType, elementType) {
|
|
2988
|
+
return new Promise((resolve) => {
|
|
2989
|
+
const http = require('http');
|
|
2990
|
+
const https = require('https');
|
|
2991
|
+
const { URL } = require('url');
|
|
2992
|
+
|
|
2993
|
+
const urlObj = new URL(url);
|
|
2994
|
+
const client = urlObj.protocol === 'https:' ? https : http;
|
|
2995
|
+
|
|
2996
|
+
const timeout = 5000; // 5 second timeout
|
|
2997
|
+
const req = client.request({
|
|
2998
|
+
hostname: urlObj.hostname,
|
|
2999
|
+
port: urlObj.port,
|
|
3000
|
+
path: urlObj.pathname + urlObj.search,
|
|
3001
|
+
method: 'HEAD',
|
|
3002
|
+
timeout: timeout,
|
|
3003
|
+
headers: {
|
|
3004
|
+
'User-Agent': 'GBU-Accessibility-Checker/3.1.0'
|
|
3005
|
+
}
|
|
3006
|
+
}, (res) => {
|
|
3007
|
+
if (res.statusCode >= 400) {
|
|
3008
|
+
resolve({
|
|
3009
|
+
type: `🔗 ${resourceType} ${res.statusCode}`,
|
|
3010
|
+
description: `${elementType} returns ${res.statusCode}: ${url}`,
|
|
3011
|
+
suggestion: `Update or remove the broken ${resourceType.toLowerCase()} reference`,
|
|
3012
|
+
url: url,
|
|
3013
|
+
statusCode: res.statusCode,
|
|
3014
|
+
resourceType: resourceType
|
|
3015
|
+
});
|
|
3016
|
+
} else {
|
|
3017
|
+
resolve(null); // No issue
|
|
3018
|
+
}
|
|
3019
|
+
});
|
|
3020
|
+
|
|
3021
|
+
req.on('error', (error) => {
|
|
3022
|
+
resolve({
|
|
3023
|
+
type: `🔗 ${resourceType} unreachable`,
|
|
3024
|
+
description: `${elementType} cannot be reached: ${url}`,
|
|
3025
|
+
suggestion: `Check network connection or update the ${resourceType.toLowerCase()} URL`,
|
|
3026
|
+
url: url,
|
|
3027
|
+
error: error.message,
|
|
3028
|
+
resourceType: resourceType
|
|
3029
|
+
});
|
|
3030
|
+
});
|
|
3031
|
+
|
|
3032
|
+
req.on('timeout', () => {
|
|
3033
|
+
req.destroy();
|
|
3034
|
+
resolve({
|
|
3035
|
+
type: `🔗 ${resourceType} timeout`,
|
|
3036
|
+
description: `${elementType} request timed out: ${url}`,
|
|
3037
|
+
suggestion: `The ${resourceType.toLowerCase()} server may be slow or unreachable`,
|
|
3038
|
+
url: url,
|
|
3039
|
+
resourceType: resourceType
|
|
3040
|
+
});
|
|
3041
|
+
});
|
|
3042
|
+
|
|
3043
|
+
req.end();
|
|
3044
|
+
});
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
async checkLocalFile(url, baseDir, resourceType, elementType) {
|
|
3048
|
+
const path = require('path');
|
|
3049
|
+
|
|
3050
|
+
// Handle relative URLs
|
|
3051
|
+
let filePath;
|
|
3052
|
+
if (url.startsWith('/')) {
|
|
3053
|
+
// Absolute path from web root - we'll check relative to baseDir
|
|
3054
|
+
filePath = path.join(baseDir, url.substring(1));
|
|
3055
|
+
} else {
|
|
3056
|
+
// Relative path
|
|
3057
|
+
filePath = path.resolve(baseDir, url);
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
try {
|
|
3061
|
+
await require('fs').promises.access(filePath);
|
|
3062
|
+
return null; // File exists, no issue
|
|
3063
|
+
} catch (error) {
|
|
3064
|
+
return {
|
|
3065
|
+
type: `📁 ${resourceType} not found`,
|
|
3066
|
+
description: `${elementType} file does not exist: ${url}`,
|
|
3067
|
+
suggestion: `Create the missing file or update the ${resourceType.toLowerCase()} path`,
|
|
3068
|
+
url: url,
|
|
3069
|
+
filePath: filePath,
|
|
3070
|
+
resourceType: resourceType
|
|
3071
|
+
};
|
|
3072
|
+
}
|
|
3073
|
+
}
|
|
3074
|
+
|
|
1570
3075
|
// Analyze headings (no auto-fix, only suggestions)
|
|
1571
3076
|
async analyzeHeadings(directory = '.') {
|
|
1572
3077
|
console.log(chalk.blue('📑 Analyzing heading structure...'));
|