const normalizeText = (value) => String(value == null ? '' : value) .replace(/\r/g, ' ') .replace(/\n/g, ' ') .replace(/\s+/g, ' ') .trim() const cleanModelText = (value) => normalizeText(value) .replace(/\s+([,,/])/g, '$1') .replace(/([((])\s+/g, '$1') .replace(/\s+([))])/g, '$1') .replace(/\s+/g, ' ') .trim() const MULTI_UNIT_HINT_PATTERNS = [ { pattern: 'cpu_x_multiplier', regex: /[((]\s*[xX]\s*[2-9]\d*\s*[))]\s*$/ }, { pattern: 'suffix_quantity_label', regex: /\*\s*[2-9]\d*\s*(个|条|根|块|张|颗)\s*$/ }, { pattern: 'bracket_capacity_multiplier', regex: /[((][^()()]*\d+\s*[A-Za-z]{0,3}\s*\*\s*[2-9]\d*[^()()]*[))]/i }, { pattern: 'inline_capacity_multiplier', regex: /(?:^|\s)\d+\s*[A-Za-z]{0,3}\s*\*\s*[2-9]\d*(?:\s|$)/i }, { pattern: 'suffix_multiplier_with_paren_unit', regex: /\*\s*[2-9]\d*\s*[((]\s*\d+\s*[A-Za-z]{1,3}\s*[))]\s*$/i }, { pattern: 'plus_capacity_pair', regex: /\d+(?:\.\d+)?\s*[A-Za-z]{0,3}\s*\+\s*\d+(?:\.\d+)?\s*[A-Za-z]{0,3}/i } ] const CAPACITY_TOKEN_REGEX = /(\d+(?:\.\d+)?)\s*([A-Za-z]{1,3})/g const normalizeCapacityUnit = (value) => { const unit = String(value || '').trim().toUpperCase() if (unit === 'GB') return 'G' if (unit === 'TB') return 'T' return unit } const parseCapacityToken = (value) => { const matched = normalizeText(value).match(/^(\d+(?:\.\d+)?)\s*([A-Za-z]{0,3})$/) if (!matched) { return null } return { raw: matched[0], value: Number(matched[1]), unit: matched[2] || '', normalizedUnit: normalizeCapacityUnit(matched[2] || '') } } const formatCapacityToken = (value, unit) => { const numeric = Number(value) const rendered = Number.isInteger(numeric) ? String(numeric) : String(numeric) return `${rendered}${unit || ''}` } const findLastCapacityTokenWithUnit = (value) => { const text = String(value || '') let matched = null let current = null while ((current = CAPACITY_TOKEN_REGEX.exec(text)) !== null) { matched = { raw: current[0], value: Number(current[1]), unit: current[2], normalizedUnit: normalizeCapacityUnit(current[2]), index: current.index, length: current[0].length } } CAPACITY_TOKEN_REGEX.lastIndex = 0 return matched } const findFirstCapacityTokenWithUnit = (value) => { const text = String(value || '') const matched = CAPACITY_TOKEN_REGEX.exec(text) CAPACITY_TOKEN_REGEX.lastIndex = 0 if (!matched) { return null } return { raw: matched[0], value: Number(matched[1]), unit: matched[2], normalizedUnit: normalizeCapacityUnit(matched[2]), index: matched.index, length: matched[0].length } } const countCapacityTokensWithUnit = (value) => { const text = String(value || '') const matched = text.match(/(\d+(?:\.\d+)?)\s*([A-Za-z]{1,3})/g) return matched ? matched.length : 0 } const splitAroundFirstCapacityToken = (value) => { const text = normalizeText(value) const token = findFirstCapacityTokenWithUnit(text) if (!token) { return null } return { prefix: normalizeText(text.slice(0, token.index)), token, suffix: normalizeText(text.slice(token.index + token.length)) } } const buildModelText = (prefix, token, suffix) => cleanModelText(`${prefix || ''}${formatCapacityToken(token.value, token.unit)}${suffix ? ` ${suffix}` : ''}`) const isMemoryChildType = (childType) => normalizeText(childType) === '内存条' const isStorageChildType = (childType) => ['固态硬盘', '机械硬盘'].includes(normalizeText(childType)) const canShareMemoryPrefix = (value) => { const text = normalizeText(value) return Boolean(text) && !/[+/]/.test(text) && !/\d/.test(text) } const canShareMemorySuffix = (value) => { const text = normalizeText(value) return Boolean(text) && !/[+/]/.test(text) && !/[\\/]/.test(text) } const replaceTokenByIndex = (value, token, replacement) => `${value.slice(0, token.index)}${replacement}${value.slice(token.index + token.length)}` const removeBundleKeyword = (value) => normalizeText(value) .replace(/^套装/i, '') .replace(/套装/gi, ' ') .replace(/\s+/g, ' ') .trim() const detectMultiUnitHint = (value) => { const text = normalizeText(value) if (!text) { return null } return MULTI_UNIT_HINT_PATTERNS.find((item) => item.regex.test(text))?.pattern || null } const resolveBracketCapacitySplit = (value) => { const text = normalizeText(value) const matched = text.match(/^(.*?)[((]\s*(\d+\s*[A-Za-z]{0,3}|\d+)\s*\*\s*([2-9]\d*)\s*[))](.*)$/i) if (!matched) { return null } const prefix = matched[1] || '' const rawInner = matched[2] || '' const quantity = Number(matched[3] || 0) const suffix = matched[4] || '' const inner = parseCapacityToken(rawInner) const outer = findLastCapacityTokenWithUnit(prefix) if (!inner || !outer || quantity < 2) { return { isMultiUnit: true, isAutoSplittable: false, reason: 'missing_outer_or_inner_capacity_token', pattern: 'bracket_capacity_multiplier', originalModel: text } } const unitToken = formatCapacityToken(inner.value, inner.unit || outer.unit) if (outer.value !== inner.value * quantity) { return { isMultiUnit: true, isAutoSplittable: false, reason: 'outer_capacity_not_equal_to_inner_times_quantity', pattern: 'bracket_capacity_multiplier', originalModel: text, quantity } } const updatedPrefix = replaceTokenByIndex(prefix, outer, unitToken) const unitModel = cleanModelText(`${updatedPrefix}${removeBundleKeyword(suffix) ? ` ${removeBundleKeyword(suffix)}` : ''}`) return { isMultiUnit: true, isAutoSplittable: true, quantity, unitModel, pattern: 'bracket_capacity_multiplier', originalModel: text } } const resolveInlineCapacitySplit = (value) => { const text = normalizeText(value) const matched = text.match(/^(.*?)(\d+\s*[A-Za-z]{0,3}|\d+)\s*\*\s*([2-9]\d*)\s*$/i) if (!matched) { return null } const prefix = matched[1] || '' const rawInner = matched[2] || '' const quantity = Number(matched[3] || 0) const inner = parseCapacityToken(rawInner) const outer = findLastCapacityTokenWithUnit(prefix) if (!inner || !outer || quantity < 2) { return { isMultiUnit: true, isAutoSplittable: false, reason: 'missing_outer_or_inner_capacity_token', pattern: 'inline_capacity_multiplier', originalModel: text } } const unitToken = formatCapacityToken(inner.value, inner.unit || outer.unit) if (outer.value !== inner.value * quantity) { return { isMultiUnit: true, isAutoSplittable: false, reason: 'outer_capacity_not_equal_to_inner_times_quantity', pattern: 'inline_capacity_multiplier', originalModel: text, quantity } } const updatedPrefix = replaceTokenByIndex(prefix, outer, unitToken) const unitModel = cleanModelText(updatedPrefix) return { isMultiUnit: true, isAutoSplittable: true, quantity, unitModel, pattern: 'inline_capacity_multiplier', originalModel: text } } const resolveSuffixMultiplierWithParenUnit = (value) => { const text = normalizeText(value) const matched = text.match(/^(.*)\*\s*([2-9]\d*)\s*[((]\s*(\d+\s*[A-Za-z]{1,3})\s*[))]\s*$/i) if (!matched) { return null } const prefix = normalizeText(matched[1] || '') const quantity = Number(matched[2] || 0) const rawInner = normalizeText(matched[3] || '') if (quantity < 2 || !prefix || !rawInner) { return { isMultiUnit: true, isAutoSplittable: false, reason: 'invalid_suffix_multiplier_with_paren_unit', pattern: 'suffix_multiplier_with_paren_unit', originalModel: text } } if (!prefix.includes(rawInner)) { return { isMultiUnit: true, isAutoSplittable: false, reason: 'paren_unit_not_present_in_prefix', pattern: 'suffix_multiplier_with_paren_unit', originalModel: text, quantity } } return { isMultiUnit: true, isAutoSplittable: true, quantity, unitModel: cleanModelText(prefix), pattern: 'suffix_multiplier_with_paren_unit', originalModel: text } } const resolveCpuXMultiplier = (value) => { const text = normalizeText(value) const matched = text.match(/^(.*?)[((]\s*[xX]\s*([2-9]\d*)\s*[))]\s*$/) if (!matched) { return null } return { isMultiUnit: true, isAutoSplittable: true, quantity: Number(matched[2] || 0), unitModel: cleanModelText(matched[1]), pattern: 'cpu_x_multiplier', originalModel: text } } const resolveSuffixQuantityLabel = (value) => { const text = normalizeText(value) const matched = text.match(/^(.*?)\s*\*\s*([2-9]\d*)\s*(个|条|根|块|张|颗)\s*$/) if (!matched) { return null } return { isMultiUnit: true, isAutoSplittable: true, quantity: Number(matched[2] || 0), unitModel: cleanModelText(matched[1]), pattern: 'suffix_quantity_label', originalModel: text } } const resolveMemoryPlusSplit = (childType, value) => { if (!isMemoryChildType(childType)) { return null } const text = normalizeText(value) const parts = text.split('+') if (parts.length !== 2) { return null } const left = splitAroundFirstCapacityToken(parts[0]) const right = splitAroundFirstCapacityToken(parts[1]) if (!left || !right || countCapacityTokensWithUnit(parts[0]) !== 1 || countCapacityTokensWithUnit(parts[1]) !== 1) { return { isMultiUnit: true, isAutoSplittable: false, quantity: 0, unitModel: '', reason: 'memory_plus_pattern_is_not_single_capacity_pair', pattern: 'plus_capacity_pair', originalModel: text } } const leftPrefix = left.prefix || (canShareMemoryPrefix(right.prefix) ? right.prefix : '') const rightPrefix = right.prefix || (canShareMemoryPrefix(left.prefix) ? left.prefix : '') const leftSuffix = left.suffix || (canShareMemorySuffix(right.suffix) ? right.suffix : '') const rightSuffix = right.suffix || (canShareMemorySuffix(left.suffix) ? left.suffix : '') const models = [ buildModelText(leftPrefix, left.token, leftSuffix), buildModelText(rightPrefix, right.token, rightSuffix) ] return { isMultiUnit: true, isAutoSplittable: true, quantity: models.length, unitModel: models[0] === models[1] ? models[0] : '', unitModels: models, pattern: 'plus_capacity_pair', originalModel: text } } const resolveStorageSimplePlusSplit = (childType, value) => { if (!isStorageChildType(childType)) { return null } const text = normalizeText(value) const parts = text.split('+') if (parts.length !== 2) { return null } const left = splitAroundFirstCapacityToken(parts[0]) const right = splitAroundFirstCapacityToken(parts[1]) if (!left || !right || countCapacityTokensWithUnit(parts[0]) !== 1 || countCapacityTokensWithUnit(parts[1]) !== 1) { return { isMultiUnit: true, isAutoSplittable: false, quantity: 0, unitModel: '', reason: 'storage_plus_pattern_is_not_single_capacity_pair', pattern: 'plus_capacity_pair', originalModel: text } } if (left.prefix || left.suffix || right.prefix || right.suffix) { return { isMultiUnit: true, isAutoSplittable: false, quantity: 0, unitModel: '', reason: 'storage_plus_pattern_contains_extra_model_details', pattern: 'plus_capacity_pair', originalModel: text } } const models = [ formatCapacityToken(left.token.value, left.token.unit), formatCapacityToken(right.token.value, right.token.unit) ] return { isMultiUnit: true, isAutoSplittable: true, quantity: models.length, unitModel: models[0] === models[1] ? models[0] : '', unitModels: models, pattern: 'plus_capacity_pair', originalModel: text } } const isTypeAndModelConsistent = (childType, model) => { const normalizedType = normalizeText(childType) const normalizedModel = normalizeText(model).toUpperCase() if (!normalizedType || !normalizedModel) { return true } if ((normalizedType === '固态硬盘' || normalizedType === '机械硬盘') && /(DDR\d|MHZ|内存|套装)/i.test(normalizedModel)) { return false } return true } const splitHardwareQuantity = (childType, value) => { const text = normalizeText(value) if (!text) { return { isMultiUnit: false, isAutoSplittable: false, quantity: 1, unitModel: '' } } const hintPattern = detectMultiUnitHint(text) if (!hintPattern) { return { isMultiUnit: false, isAutoSplittable: false, quantity: 1, unitModel: cleanModelText(text), pattern: 'single_unit', originalModel: text } } const strategies = [ (type, text) => resolveCpuXMultiplier(text), (type, text) => resolveSuffixQuantityLabel(text), (type, text) => resolveBracketCapacitySplit(text), (type, text) => resolveInlineCapacitySplit(text), (type, text) => resolveSuffixMultiplierWithParenUnit(text), resolveMemoryPlusSplit, resolveStorageSimplePlusSplit ] for (const strategy of strategies) { const result = strategy(childType, text) if (!result) { continue } if (result.isAutoSplittable && !isTypeAndModelConsistent(childType, result.unitModel)) { return { ...result, isAutoSplittable: false, reason: 'child_type_and_model_are_inconsistent' } } return result } return { isMultiUnit: true, isAutoSplittable: false, quantity: 0, unitModel: '', reason: 'pattern_detected_but_no_safe_split_rule', pattern: hintPattern, originalModel: text } } const expandHardwareModels = (childType, value) => { const result = splitHardwareQuantity(childType, value) if (!result.isMultiUnit) { return { ...result, models: result.unitModel ? [result.unitModel] : [] } } if (!result.isAutoSplittable || !result.unitModel || !result.quantity) { if (Array.isArray(result.unitModels) && result.unitModels.length > 0) { return { ...result, models: result.unitModels.map((item) => cleanModelText(item)).filter(Boolean) } } return { ...result, models: [] } } if (Array.isArray(result.unitModels) && result.unitModels.length > 0) { return { ...result, models: result.unitModels.map((item) => cleanModelText(item)).filter(Boolean) } } return { ...result, models: Array.from({ length: result.quantity }, () => result.unitModel) } } module.exports = { normalizeText, cleanModelText, detectMultiUnitHint, splitHardwareQuantity, expandHardwareModels }