Webテーブルはすべて文字列です。何もかもが文字列。しかしJSONやSQLにエクスポートする際には、こうしたいはずです:
-
"1,234.56"→1234.56(数値) -
"2024-03-15"→ 日付型 -
"Yes"→true(ブーリアン) -
"N/A"→null
信頼できる型推論システムを構築するには、現実のデータの混沌に対処する必要があります。HTML Table Exporterでの実装アプローチを紹介します。
課題:曖昧性だらけ
以下の値を考えてみてください:
| 値 | 可能性... |
|---|---|
"1,234" |
数値1234(米国)または1.234(欧州) |
"01/02/03" |
2003年1月2日(米国)、2003年2月1日(欧州)、2001年2月3日 |
"1" |
整数1またはブーリアンtrue |
"N/A" |
文字列またはnull |
"0" |
整数0またはブーリアンfalse |
型推論はパースの問題ではなく、曖昧な証拠から意図を推測する問題です。
アーキテクチャ:カラムレベルの推論
セルごとに型を推論するのは信頼性に欠けます。"1" 単体では何にでもなりえます。しかし ["1", "2", "3", "4"] を含むカラムは明らかに整数です。
私のアプローチ:
- カラムから値をサンプリング(最大100件)
- 各非null値の型を推論
- 集計してカラム型を決定
- 90%の閾値を適用——90%以上の値が型に一致すれば、その型を使用
const DATA_TYPES = {
STRING: "string",
INTEGER: "integer",
NUMBER: "number",
BOOLEAN: "boolean",
DATE: "date",
NULL: "null",
};
function inferColumnType(columnValues, maxSamples = 100) {
const nonNullValues = columnValues.filter(v => v != null && v !== "");
if (nonNullValues.length === 0) {
return { type: DATA_TYPES.STRING, confidence: 0 };
}
const sample = nonNullValues.slice(0, maxSamples);
// 各型の出現回数をカウント
const typeCounts = {
[DATA_TYPES.INTEGER]: 0,
[DATA_TYPES.NUMBER]: 0,
[DATA_TYPES.BOOLEAN]: 0,
[DATA_TYPES.DATE]: 0,
[DATA_TYPES.STRING]: 0,
};
for (const val of sample) {
const type = inferValueType(val);
if (type === DATA_TYPES.INTEGER) {
// INTEGERはNUMBERのサブセット
typeCounts[DATA_TYPES.INTEGER]++;
typeCounts[DATA_TYPES.NUMBER]++;
} else if (type !== DATA_TYPES.NULL) {
typeCounts[type]++;
}
}
const total = sample.length;
const threshold = 0.9;
// 優先順位で型をチェック
if (typeCounts[DATA_TYPES.BOOLEAN] >= total * threshold) {
return { type: DATA_TYPES.BOOLEAN, confidence: typeCounts[DATA_TYPES.BOOLEAN] / total };
}
if (typeCounts[DATA_TYPES.DATE] >= total * threshold) {
return { type: DATA_TYPES.DATE, confidence: typeCounts[DATA_TYPES.DATE] / total };
}
if (typeCounts[DATA_TYPES.INTEGER] >= total * threshold) {
return { type: DATA_TYPES.INTEGER, confidence: typeCounts[DATA_TYPES.INTEGER] / total };
}
if (typeCounts[DATA_TYPES.NUMBER] >= total * threshold) {
return { type: DATA_TYPES.NUMBER, confidence: typeCounts[DATA_TYPES.NUMBER] / total };
}
return { type: DATA_TYPES.STRING, confidence: 1 };
}
なぜ90%? 実データにはノイズがあります。100個の整数のカラムに1つの "N/A" があるかもしれません。100%一致を要求するのは厳しすぎます。
値レベルの型検出
inferValueType 関数で個々の値を処理します:
function inferValueType(value) {
if (value == null || value === "") {
return DATA_TYPES.NULL;
}
const str = String(value).trim();
if (str === "") return DATA_TYPES.NULL;
// ブーリアンチェック
const lowerStr = str.toLowerCase();
if (["true", "false", "yes", "no", "sí", "si"].includes(lowerStr)) {
return DATA_TYPES.BOOLEAN;
}
// 日付チェック(ISO形式優先)
if (/^\d{4}-\d{2}-\d{2}/.test(str)) {
return DATA_TYPES.DATE;
}
if (/^\d{1,2}[/\-\.]\d{1,2}[/\-\.]\d{2,4}$/.test(str)) {
return DATA_TYPES.DATE;
}
// 整数チェック(厳密)
if (/^-?\d+$/.test(str)) {
return DATA_TYPES.INTEGER;
}
// 数値チェック(小数点・区切り文字あり)
const cleanedForNumber = str
.replace(/[$€£¥%\s]/g, "")
.replace(/,/g, ".");
if (/^-?\d+(\.\d+)?$/.test(cleanedForNumber)) {
return DATA_TYPES.NUMBER;
}
return DATA_TYPES.STRING;
}
数値正規化の難題
欧州 vs 米国の数値フォーマットは最も難しい曖昧性です:
| 値 | 米国での解釈 | 欧州での解釈 |
|---|---|---|
"1,234" |
1234 | 1.234 |
"1.234" |
1.234 | 1234 |
"1,234.56" |
1234.56 | 無効 |
"1.234,56" |
無効 | 1234.56 |
フォーマット検出のヒューリスティック:
function normalizeNumber(value) {
if (value == null) return value;
let str = String(value).trim();
// 通貨記号と空白を除去
str = str.replace(/[$€£¥\s]/g, "");
// パーセンテージ処理
const isPercent = str.endsWith("%");
if (isPercent) str = str.slice(0, -1);
// セパレーター分析によるフォーマット検出
const commaCount = (str.match(/,/g) || []).length;
const dotCount = (str.match(/\./g) || []).length;
const lastComma = str.lastIndexOf(",");
const lastDot = str.lastIndexOf(".");
let normalized;
if (commaCount === 0 && dotCount === 0) {
normalized = str;
} else if (commaCount === 0 && dotCount === 1) {
const afterDot = str.slice(lastDot + 1);
if (afterDot.length === 3 && /^\d+$/.test(afterDot)) {
normalized = str.replace(".", "");
} else {
normalized = str;
}
} else if (commaCount === 1 && dotCount === 0) {
const afterComma = str.slice(lastComma + 1);
if (afterComma.length === 3 && /^\d+$/.test(afterComma)) {
normalized = str.replace(",", "");
} else {
normalized = str.replace(",", ".");
}
} else if (lastDot > lastComma) {
normalized = str.replace(/,/g, "");
} else if (lastComma > lastDot) {
normalized = str.replace(/\./g, "").replace(",", ".");
} else {
return value;
}
const num = parseFloat(normalized);
if (Number.isNaN(num)) return value;
return isPercent ? num / 100 : num;
}
重要なポイント: セパレーターの位置と数でほとんどの曖昧性が解消されます。最後のセパレーターが通常は小数点セパレーターです。
ブーリアン検出(落とし穴あり)
明白なブーリアン: "true", "false", "yes", "no"
しかし "0" と "1" はどうでしょう?
// 問題:税率がブーリアンに変換される
// カラム: [0, 0.05, 0.1, 0.2] -> [false, 0.05, 0.1, 0.2]
解決策:純粋な数値をブーリアンに変換しない。
function applyBooleanNormalization(rows, booleanConfig) {
const trueValues = new Set(booleanConfig.true.map(v => v.toLowerCase()));
const falseValues = new Set(booleanConfig.false.map(v => v.toLowerCase()));
return rows.map((row, rowIndex) => {
if (rowIndex === 0) return row; // ヘッダーをスキップ
return row.map(cell => {
if (cell == null) return cell;
const cellStr = String(cell).toLowerCase().trim();
// 純粋な数値はスキップ(数値カラムで0→falseを防ぐ)
if (/^-?\d+(\.\d+)?$/.test(cellStr)) {
return cell;
}
if (trueValues.has(cellStr)) return "true";
if (falseValues.has(cellStr)) return "false";
return cell;
});
});
}
Null値の処理
Webデータは「値なし」を様々な方法で表現します:
-
""(空文字列) -
"N/A","n/a","NA" -
"-","--" -
"null","NULL" -
"none","None" -
"."(政府データで見られる)
設定可能なnull検出:
function applyNullValues(rows, nullPatterns) {
const nullSet = new Set(nullPatterns.map(v => v.toLowerCase().trim()));
return rows.map((row, rowIndex) => {
if (rowIndex === 0) return row; // ヘッダーをスキップ
return row.map(cell => {
if (cell == null) return null;
const cellStr = String(cell).toLowerCase().trim();
return nullSet.has(cellStr) ? null : cell;
});
});
}
// 使用例
applyNullValues(rows, ["N/A", "n/a", "-", "--", "null", "none", "."]);
パイプライン
型推論とクリーニングの完全パイプライン:
function cleanTable(tableInfo, config) {
let rows = cloneRows(tableInfo.rows);
// 1. 全文字列をトリム
if (config.trimStrings) {
rows = applyTrimStrings(rows);
}
// 2. nullパターンを実際のnullに変換
if (config.nullValues?.length) {
rows = applyNullValues(rows, config.nullValues);
}
// 3. ブーリアンを正規化(数値の前に。0→false問題を回避)
if (config.booleans) {
rows = applyBooleanNormalization(rows, config.booleans);
}
// 4. 数値を正規化(EU/USフォーマット対応)
if (config.normalizeNumbers) {
rows = applyNumberNormalization(rows);
}
// 5. 日付を正規化(オプション、フォーマット指定)
if (config.normalizeDates) {
rows = applyDateNormalization(rows, config.dateFormat);
}
return { ...tableInfo, rows };
}
エッジケースのテスト
型推論にはエッジケースが多数あります。テストスイートを維持しています:
// 数値正規化テスト
assertEqual(normalizeNumber("1,234.56"), 1234.56); // 米国フォーマット
assertEqual(normalizeNumber("1.234,56"), 1234.56); // 欧州フォーマット
assertEqual(normalizeNumber("€1.234,56"), 1234.56); // 通貨付き
assertEqual(normalizeNumber("45,5%"), 0.455); // パーセンテージ
assertEqual(normalizeNumber("1.234"), 1234); // 欧州の千の位
assertEqual(normalizeNumber("1.23"), 1.23); // 小数
// ブーリアンテスト
assertEqual(inferValueType("Yes"), DATA_TYPES.BOOLEAN);
assertEqual(inferValueType("0"), DATA_TYPES.INTEGER); // ブーリアンではない
assertEqual(inferValueType("0.5"), DATA_TYPES.NUMBER); // ブーリアンではない
学んだこと
カラムのコンテキストはセル分析に勝る。 単独の
"1"は曖昧。整数のカラムは明確。変換は保守的に。 誤った変換でデータを破壊するより、文字列のまま残す方がよい。
設定可能にする。 ドメインによってnull値、ブーリアン表現、数値フォーマットは異なる。
実データでテスト。 合成テストでは実際のWebテーブルの混沌を見逃す。
このシステムはHTML Table Exporterのデータクリーニングを支えています。これらの課題の実例として、テーブルをExcelにコピーするのに最適なChrome拡張機能で、適切な型推論がどのようにフォーマット崩れを修正するかをご覧ください。
PRO版ではユーザーがエクスポートプロファイルごとにこれらのルールを設定できます。詳しくは gauchogrid.com/ja/html-table-exporter をご覧いただくか、Chrome Web Store でお試しください。
別のドメインで型推論を構築中ですか?エッジケースについてぜひお聞かせください。
Top comments (0)