/** * エンコーディング検出器 * * @requirements REQ-VISIT-003-01 * @design DES-VISIT-003-01 * @version 1.11.0 */ import { SupportedEncoding, EncodingDetectionResult, EncodingDetectionSource, ENCODING_ALIASES, BOM_MAP, } from './types.js'; /** * エンコーディング検出器 * Content-Type、meta charset、BOM、ヒューリスティックの順で検出 */ export class EncodingDetector { /** * エンコーディングを検出 * @param content - コンテンツ(Buffer) * @param contentType - Content-Typeヘッダー値(オプション) * @param html - HTMLコンテンツ(meta charset検出用、オプション) */ detect( content: Buffer, contentType?: string, html?: string ): EncodingDetectionResult { // 1. Content-Type charset から検出 if (contentType) { const result = this.detectFromContentType(contentType); if (result) { return result; } } // 2. HTML meta charset から検出 if (html) { const result = this.detectFromMetaCharset(html); if (result) { return result; } } // 3. BOM から検出 const bomResult = this.detectFromBom(content); if (bomResult) { return bomResult; } // 4. ヒューリスティック検出 return this.detectHeuristic(content); } /** * Content-Type ヘッダーからエンコーディングを検出 */ detectFromContentType(contentType: string): EncodingDetectionResult | null { // charset=xxx を抽出 const charsetMatch = contentType.match(/charset=([^\s;]+)/i); if (!charsetMatch) { return null; } const charset = charsetMatch[1].toLowerCase().replace(/['"]/g, ''); const normalized = this.normalizeEncoding(charset); if (normalized) { return { detected: normalized, confidence: 0.95, source: 'content-type', details: `Content-Type header: ${charset}`, }; } return null; } /** * HTML meta charset からエンコーディングを検出 */ detectFromMetaCharset(html: string): EncodingDetectionResult | null { // パターン const metaCharsetMatch = html.match(/]+)/i); if (metaCharsetMatch) { const charset = metaCharsetMatch[1].toLowerCase(); const normalized = this.normalizeEncoding(charset); if (normalized) { return { detected: normalized, confidence: 0.9, source: 'meta-charset', details: `meta charset: ${charset}`, }; } } // パターン const httpEquivMatch = html.match( /]+)/i ); if (httpEquivMatch) { const charset = httpEquivMatch[1].toLowerCase(); const normalized = this.normalizeEncoding(charset); if (normalized) { return { detected: normalized, confidence: 0.9, source: 'meta-charset', details: `meta http-equiv: ${charset}`, }; } } // 逆順: content → http-equiv const httpEquivMatch2 = html.match( /]+)["']?\s+http-equiv=["']?Content-Type/i ); if (httpEquivMatch2) { const charset = httpEquivMatch2[1].toLowerCase(); const normalized = this.normalizeEncoding(charset); if (normalized) { return { detected: normalized, confidence: 0.9, source: 'meta-charset', details: `meta http-equiv: ${charset}`, }; } } return null; } /** * BOM (Byte Order Mark) からエンコーディングを検出 */ detectFromBom(content: Buffer): EncodingDetectionResult | null { // UTF-8 BOM: EF BB BF if ( content.length >= 3 && content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf ) { return { detected: 'utf-8', confidence: 1.0, source: 'bom', details: 'UTF-8 BOM detected', }; } // UTF-16 BE BOM: FE FF if (content.length >= 2 && content[0] === 0xfe && content[1] === 0xff) { return { detected: 'utf-8', // UTF-16BEとして扱うがUTF-8で返す confidence: 0.8, source: 'bom', details: 'UTF-16BE BOM detected (treating as UTF-8)', }; } // UTF-16 LE BOM: FF FE if (content.length >= 2 && content[0] === 0xff && content[1] === 0xfe) { return { detected: 'utf-8', // UTF-16LEとして扱うがUTF-8で返す confidence: 0.8, source: 'bom', details: 'UTF-16LE BOM detected (treating as UTF-8)', }; } return null; } /** * ヒューリスティックにエンコーディングを検出 */ detectHeuristic(content: Buffer): EncodingDetectionResult { // サンプルサイズ(最大4KB) const sampleSize = Math.min(content.length, 4096); const sample = content.subarray(0, sampleSize); // UTF-8として有効かチェック if (this.isValidUtf8(sample)) { return { detected: 'utf-8', confidence: 0.7, source: 'heuristic', details: 'Valid UTF-8 sequence detected', }; } // Shift_JISの特徴をチェック if (this.looksLikeShiftJis(sample)) { return { detected: 'shift_jis', confidence: 0.6, source: 'heuristic', details: 'Shift_JIS pattern detected', }; } // EUC-JPの特徴をチェック if (this.looksLikeEucJp(sample)) { return { detected: 'euc-jp', confidence: 0.6, source: 'heuristic', details: 'EUC-JP pattern detected', }; } // ISO-2022-JPの特徴をチェック(エスケープシーケンス) if (this.looksLikeIso2022Jp(sample)) { return { detected: 'iso-2022-jp', confidence: 0.7, source: 'heuristic', details: 'ISO-2022-JP escape sequence detected', }; } // デフォルトはUTF-8 return { detected: 'utf-8', confidence: 0.4, source: 'heuristic', details: 'Fallback to UTF-8', }; } /** * 有効なUTF-8シーケンスかチェック */ private isValidUtf8(buffer: Buffer): boolean { let i = 0; while (i < buffer.length) { const byte = buffer[i]; if (byte < 0x80) { // ASCII i++; } else if ((byte & 0xe0) === 0xc0) { // 2バイト文字 if (i + 1 >= buffer.length || (buffer[i + 1] & 0xc0) !== 0x80) { return false; } i += 2; } else if ((byte & 0xf0) === 0xe0) { // 3バイト文字 if ( i + 2 >= buffer.length || (buffer[i + 1] & 0xc0) !== 0x80 || (buffer[i + 2] & 0xc0) !== 0x80 ) { return false; } i += 3; } else if ((byte & 0xf8) === 0xf0) { // 4バイト文字 if ( i + 3 >= buffer.length || (buffer[i + 1] & 0xc0) !== 0x80 || (buffer[i + 2] & 0xc0) !== 0x80 || (buffer[i + 3] & 0xc0) !== 0x80 ) { return false; } i += 4; } else { return false; } } return true; } /** * Shift_JISらしいかチェック * Shift_JISの2バイト文字: 0x81-0x9F, 0xE0-0xFC + 0x40-0xFC */ private looksLikeShiftJis(buffer: Buffer): boolean { let shiftJisCount = 0; let i = 0; while (i < buffer.length - 1) { const byte1 = buffer[i]; const byte2 = buffer[i + 1]; // Shift_JISの第1バイト範囲 if ((byte1 >= 0x81 && byte1 <= 0x9f) || (byte1 >= 0xe0 && byte1 <= 0xfc)) { // 第2バイト範囲 if ((byte2 >= 0x40 && byte2 <= 0x7e) || (byte2 >= 0x80 && byte2 <= 0xfc)) { shiftJisCount++; i += 2; continue; } } i++; } // 2バイト文字が一定数以上あればShift_JISと判断 return shiftJisCount >= 5; } /** * EUC-JPらしいかチェック * EUC-JPの2バイト文字: 0xA1-0xFE + 0xA1-0xFE */ private looksLikeEucJp(buffer: Buffer): boolean { let eucJpCount = 0; let i = 0; while (i < buffer.length - 1) { const byte1 = buffer[i]; const byte2 = buffer[i + 1]; // EUC-JPの2バイト文字範囲 if (byte1 >= 0xa1 && byte1 <= 0xfe && byte2 >= 0xa1 && byte2 <= 0xfe) { eucJpCount++; i += 2; continue; } i++; } // 2バイト文字が一定数以上あればEUC-JPと判断 return eucJpCount >= 5; } /** * ISO-2022-JPらしいかチェック * ISO-2022-JPはエスケープシーケンスを使用 */ private looksLikeIso2022Jp(buffer: Buffer): boolean { // ESC $ B (JIS X 0208へ切り替え) // ESC ( B (ASCIIへ切り替え) const bufferStr = buffer.toString('binary'); return ( bufferStr.includes('\x1b$B') || bufferStr.includes('\x1b(B') || bufferStr.includes('\x1b$@') || bufferStr.includes('\x1b(J') ); } /** * エンコーディング名を正規化 */ normalizeEncoding(encoding: string): SupportedEncoding | null { const lower = encoding.toLowerCase().trim(); return ENCODING_ALIASES[lower] || null; } }