FE_Dev / server /services /html-preview /contents /html-image.analyzer.ts
GitHub Actions
Deploy from GitHub Actions [dev] - 2025-10-31 07:28:50
68f7925
import { JSDOM } from 'jsdom';
/**
* HTML内の画像要素情報
*/
export interface ImageElement {
/** 要素のユニークID */
id: string;
/** 元のsrc属性値 */
originalSrc: string;
/** alt属性値 */
altText: string;
/** 画像の幅 */
width: number;
/** 画像の高さ */
height: number;
/** 周辺のコンテキスト情報 */
context: ImageContext;
/** HTMLでの位置(DOM内のセレクタ) */
selector: string;
}
/**
* 画像のコンテキスト情報
*/
export interface ImageContext {
/** セクションのタイトル */
sectionTitle: string;
/** 前後のテキスト */
surroundingText: string;
/** セクションのタイプ(problem, solution, features等) */
sectionType: string;
/** 親要素のクラス名 */
parentClasses: string[];
/** 画像の役割(メイン画像、アイコン、装飾等) */
imageRole: 'main' | 'icon' | 'decoration' | 'illustration';
}
/**
* HTMLから画像要素を抽出・解析するサービス
*/
export class HtmlImageAnalyzer {
/**
* HTMLから画像要素を抽出し、各要素の詳細情報を取得
*/
static extractImageElements(html: string): ImageElement[] {
const dom = new JSDOM(html);
const document = dom.window.document;
// プレースホルダー画像のパターン
const placeholderPatterns = [/\/placeholder\.svg/, /https:\/\/images\.pexels\.com/, /https:\/\/via\.placeholder\.com/, /placeholder/i];
const imageElements: ImageElement[] = [];
const images = document.querySelectorAll('img');
images.forEach((img, index) => {
const src = img.getAttribute('src') || '';
const id = img.getAttribute('id') || '';
const alt = img.getAttribute('alt') || '';
// プレースホルダー画像かチェック
const isPlaceholder = placeholderPatterns.some((pattern) => pattern.test(src));
if (isPlaceholder) {
// 元のHTML内のIDがあれば必ずそれを使用
const existingId = img.getAttribute('id');
const imageId = existingId || `img-${index}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const imageElement: ImageElement = {
id: imageId, // 必ず既存IDまたは新規IDを使用
originalSrc: src,
altText: alt,
width: this.extractDimension(src, 'width') || 800,
height: this.extractDimension(src, 'height') || 400,
context: this.analyzeImageContext(img, document),
selector: this.generateSelector(img),
};
imageElements.push(imageElement);
}
});
return imageElements;
}
/**
* 画像のコンテキスト情報を解析
*/
private static analyzeImageContext(img: Element, document: Document): ImageContext {
// 親セクションを特定
const section = img.closest('section');
const parentDiv = img.closest('div[class*="container"], div[class*="content"]');
// セクションタイトルを取得
const sectionTitle = this.findSectionTitle(section || parentDiv || img);
// 周辺テキストを取得
const surroundingText = this.extractSurroundingText(img, 300);
// セクションタイプを推定
const sectionType = this.detectSectionType(sectionTitle, surroundingText, img);
// 親要素のクラス名を取得
const parentClasses = this.getParentClasses(img);
// 画像の役割を推定
const imageRole = this.detectImageRole(img, parentClasses);
return {
sectionTitle,
surroundingText,
sectionType,
parentClasses,
imageRole,
};
}
/**
* セクションのタイトルを取得
*/
private static findSectionTitle(container: Element): string {
if (!container) return '';
// 見出し要素を検索
const headings = container.querySelectorAll('h1, h2, h3, h4, h5, h6');
if (headings.length > 0) {
return headings[0].textContent?.trim() || '';
}
// タイトルクラスを持つ要素を検索
const titleElements = container.querySelectorAll('[class*="title"], [class*="heading"]');
if (titleElements.length > 0) {
return titleElements[0].textContent?.trim() || '';
}
return '';
}
/**
* 画像周辺のテキストを抽出
*/
private static extractSurroundingText(img: Element, maxLength: number = 300): string {
const container = img.closest('section, div[class*="container"], div[class*="content"]') || img.parentElement;
if (!container) return '';
const text = container.textContent?.replace(/\s+/g, ' ').trim() || '';
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
}
/**
* セクションタイプを推定
*/
private static detectSectionType(title: string, text: string, img: Element): string {
const combinedText = `${title} ${text}`.toLowerCase();
// キーワードベースの判定
if (combinedText.includes('問題') || combinedText.includes('課題') || combinedText.includes('悩み')) {
return 'problem';
}
if (combinedText.includes('解決') || combinedText.includes('ソリューション') || combinedText.includes('改善')) {
return 'solution';
}
if (combinedText.includes('特徴') || combinedText.includes('メリット') || combinedText.includes('強み')) {
return 'features';
}
if (combinedText.includes('サービス') || combinedText.includes('詳細') || combinedText.includes('機能')) {
return 'service';
}
if (combinedText.includes('流れ') || combinedText.includes('ステップ') || combinedText.includes('手順')) {
return 'flow';
}
if (combinedText.includes('実績') || combinedText.includes('事例') || combinedText.includes('成果')) {
return 'achievements';
}
if (combinedText.includes('お客様') || combinedText.includes('声') || combinedText.includes('評価')) {
return 'testimonials';
}
if (combinedText.includes('料金') || combinedText.includes('価格') || combinedText.includes('プラン')) {
return 'pricing';
}
// クラス名からの推定
const parentClasses = this.getParentClasses(img).join(' ').toLowerCase();
if (parentClasses.includes('hero') || parentClasses.includes('main')) {
return 'hero';
}
if (parentClasses.includes('feature') || parentClasses.includes('benefit')) {
return 'features';
}
if (parentClasses.includes('service') || parentClasses.includes('product')) {
return 'service';
}
return 'generic';
}
/**
* 親要素のクラス名を取得
*/
private static getParentClasses(img: Element): string[] {
const classes: string[] = [];
let current = img.parentElement;
let depth = 0;
while (current && depth < 5) {
if (current.className && typeof current.className === 'string') {
classes.push(...current.className.split(' ').filter(Boolean));
}
current = current.parentElement;
depth++;
}
return classes;
}
/**
* 画像の役割を推定
*/
private static detectImageRole(img: Element, parentClasses: string[]): ImageContext['imageRole'] {
const imgClasses = (img.className || '').toLowerCase();
const parentClassStr = parentClasses.join(' ').toLowerCase();
// アイコン系
if (imgClasses.includes('icon') || parentClassStr.includes('icon') || imgClasses.includes('avatar') || parentClassStr.includes('avatar')) {
return 'icon';
}
// メイン画像系
if (imgClasses.includes('hero') || imgClasses.includes('main') || parentClassStr.includes('hero') || parentClassStr.includes('main')) {
return 'main';
}
// 装飾系
if (
imgClasses.includes('decoration') ||
imgClasses.includes('bg') ||
parentClassStr.includes('decoration') ||
parentClassStr.includes('background')
) {
return 'decoration';
}
// デフォルトはイラスト
return 'illustration';
}
/**
* URLパラメータから寸法を抽出
*/
private static extractDimension(src: string, dimension: 'width' | 'height'): number | null {
const match = src.match(new RegExp(`${dimension}=(\\d+)`));
return match ? parseInt(match[1], 10) : null;
}
/**
* 要素のセレクタを生成
*/
private static generateSelector(img: Element): string {
const tagName = img.tagName.toLowerCase();
const id = img.id ? `#${img.id}` : '';
const classes = img.className ? `.${img.className.split(' ').join('.')}` : '';
if (id) return `${tagName}${id}`;
if (classes) return `${tagName}${classes}`;
// インデックスベースのセレクタ
const parent = img.parentElement;
if (parent) {
const siblings = Array.from(parent.children).filter((child) => child.tagName === img.tagName);
const index = siblings.indexOf(img);
return `${parent.tagName.toLowerCase()} > ${tagName}:nth-of-type(${index + 1})`;
}
return tagName;
}
}