535 lines
14 KiB
JavaScript
535 lines
14 KiB
JavaScript
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
|
||
}
|