diff --git a/utils/shippingAddress.js b/utils/shippingAddress.js index 822b720..d10655c 100644 --- a/utils/shippingAddress.js +++ b/utils/shippingAddress.js @@ -7,30 +7,59 @@ const GENERIC_CITY_LABELS = new Set([ '省直辖县级行政区划' ]); -const NAME_LABEL_REG = /(收货人|收件人|联系人|姓名)/g; -const PHONE_LABEL_REG = /(手机号码|手机号|联系电话|联系方式|电话|手机)/g; -const ADDRESS_LABEL_REG = /(收货地址|详细地址|地址|所在地区|地区)/g; -const ADDRESS_SIGNAL_REG = /(省|市|区|县|旗|乡|镇|街道|大道|路|街|巷|弄|村|社区|小区|号|栋|单元|室|楼)/; +const NAME_LABELS = ['收货人', '收件人', '联系人', '姓名']; +const PHONE_LABELS = ['手机号码', '手机号', '联系电话', '联系方式', '电话', '手机']; +const REGION_LABELS = ['所在地区', '地区']; +const DETAIL_LABELS = ['详细地址', '收货地址', '地址']; +const ALL_FIELD_LABELS = [ + ...new Set([...NAME_LABELS, ...PHONE_LABELS, ...REGION_LABELS, ...DETAIL_LABELS]) +]; + const PHONE_REG = /(? ({ label, value })); const districtRecords = regionData.flatMap((province) => (province.children || []).flatMap((city) => - (city.children || []).map((district) => ({ - province, - city, - district, - provinceSearchLabel: normalizeForSearch(province.label), - citySearchLabel: normalizeForSearch(getDisplayCityLabel(city.label)), - districtSearchLabel: normalizeForSearch(district.label), - fullSearchLabel: normalizeForSearch( - `${province.label}${getDisplayCityLabel(city.label)}${district.label}` - ) - })) + (city.children || []).map((district) => { + const provinceAliases = createProvinceAliases(province.label); + const cityAliases = createCityAliases(getDisplayCityLabel(city.label)); + const districtAliases = createDistrictAliases(district.label); + + return { + province, + city, + district, + provinceAliases, + cityAliases, + districtAliases, + provinceSearchAliases: provinceAliases.map(normalizeForSearch).filter(Boolean), + citySearchAliases: cityAliases.map(normalizeForSearch).filter(Boolean), + districtSearchAliases: districtAliases.map(normalizeForSearch).filter(Boolean), + fullSearchLabels: buildFullSearchLabels(provinceAliases, cityAliases, districtAliases) + }; + }) ) ); +function escapeRegExp(value = '') { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function createLabelPattern(labels = []) { + return labels + .slice() + .sort((left, right) => right.length - left.length) + .map((label) => escapeRegExp(label)) + .join('|'); +} + function normalizeText(value = '') { return String(value) .replace(/\r/g, '\n') @@ -43,6 +72,64 @@ function normalizeForSearch(value = '') { return normalizeText(value).replace(/[\s,,.。;;::、\-_/]/g, ''); } +function stripProvinceSuffix(label = '') { + return normalizeText(label).replace( + /(特别行政区|维吾尔自治区|壮族自治区|回族自治区|自治区|省|市)$/u, + '' + ); +} + +function stripCitySuffix(label = '') { + return normalizeText(label).replace(/(自治州|地区|盟|市)$/u, ''); +} + +function stripDistrictSuffix(label = '') { + return normalizeText(label).replace(/(自治县|自治旗|区|县|旗|市)$/u, ''); +} + +function createAliases(label = '', stripFn = null) { + const rawLabel = normalizeText(label); + const aliases = new Set([rawLabel]); + const simplifiedLabel = stripFn ? normalizeText(stripFn(rawLabel)) : ''; + + if (simplifiedLabel && simplifiedLabel !== rawLabel) { + aliases.add(simplifiedLabel); + } + + return [...aliases].filter(Boolean); +} + +function createProvinceAliases(label = '') { + return createAliases(label, stripProvinceSuffix); +} + +function createCityAliases(label = '') { + if (!label) return ['']; + return createAliases(label, stripCitySuffix); +} + +function createDistrictAliases(label = '') { + return createAliases(label, stripDistrictSuffix); +} + +function buildFullSearchLabels(provinceAliases = [], cityAliases = [], districtAliases = []) { + const fullLabels = new Set(); + const normalizedCityAliases = cityAliases.length ? cityAliases : ['']; + + provinceAliases.forEach((provinceAlias) => { + normalizedCityAliases.forEach((cityAlias) => { + districtAliases.forEach((districtAlias) => { + const combinedLabel = normalizeForSearch(`${provinceAlias}${cityAlias}${districtAlias}`); + if (combinedLabel) { + fullLabels.add(combinedLabel); + } + }); + }); + }); + + return [...fullLabels]; +} + function getDisplayCityLabel(label = '') { return GENERIC_CITY_LABELS.has(label) ? '' : label; } @@ -90,17 +177,32 @@ function getLabelParts(regionCodes = []) { return [province.label, displayCityLabel, district.label].filter(Boolean); } +function stripFieldLabels(value = '') { + return normalizeText(value).replace(STRIP_FIELD_LABEL_REG, '$1'); +} + function cleanupAddressText(value = '') { - return normalizeText(value) - .replace(NAME_LABEL_REG, ' ') - .replace(PHONE_LABEL_REG, ' ') - .replace(ADDRESS_LABEL_REG, ' ') + return stripFieldLabels(value) .replace(/[::]/g, ' ') .replace(/[,,;;]/g, ' ') .replace(/\s+/g, ' ') .trim(); } +function cleanupName(value = '') { + return normalizeText(value) + .replace(/^[\s,,;;::]+/, '') + .replace(/[\s,,;;::]+$/, '') + .trim(); +} + +function findBestAliasInText(text = '', aliases = []) { + return aliases + .slice() + .sort((left, right) => right.length - left.length) + .find((alias) => alias && text.includes(alias)) || ''; +} + function extractPhone(rawText = '') { const text = normalizeText(rawText); const match = text.match(PHONE_REG); @@ -125,12 +227,16 @@ function pickBestMatch(records, matcher) { .filter(matcher) .sort((left, right) => { return ( - right.fullSearchLabel.length - left.fullSearchLabel.length || - right.districtSearchLabel.length - left.districtSearchLabel.length + right.fullSearchLabels[0]?.length - left.fullSearchLabels[0]?.length || + right.district.label.length - left.district.label.length ); })[0] || null; } +function matchAnyAlias(searchText = '', aliases = []) { + return aliases.some((alias) => alias && searchText.includes(alias)); +} + function findRegionMatch(rawText = '') { const searchText = normalizeForSearch(rawText); if (!searchText) { @@ -138,16 +244,24 @@ function findRegionMatch(rawText = '') { } let match = pickBestMatch(districtRecords, (record) => { - return record.fullSearchLabel && searchText.includes(record.fullSearchLabel); + return record.fullSearchLabels.some((label) => searchText.includes(label)); }); if (!match) { match = pickBestMatch(districtRecords, (record) => { return ( - record.provinceSearchLabel && - record.districtSearchLabel && - searchText.includes(record.provinceSearchLabel) && - searchText.includes(record.districtSearchLabel) + matchAnyAlias(searchText, record.provinceSearchAliases) && + matchAnyAlias(searchText, record.districtSearchAliases) && + (!record.citySearchAliases.length || matchAnyAlias(searchText, record.citySearchAliases)) + ); + }); + } + + if (!match) { + match = pickBestMatch(districtRecords, (record) => { + return ( + matchAnyAlias(searchText, record.citySearchAliases) && + matchAnyAlias(searchText, record.districtSearchAliases) ); }); } @@ -156,8 +270,11 @@ function findRegionMatch(rawText = '') { } function getRegionStartIndex(text, match) { - const cityLabel = getDisplayCityLabel(match.city.label); - const candidates = [match.province.label, cityLabel, match.district.label] + const candidates = [ + findBestAliasInText(text, match.provinceAliases), + findBestAliasInText(text, match.cityAliases), + findBestAliasInText(text, match.districtAliases) + ] .filter(Boolean) .map((label) => text.indexOf(label)) .filter((index) => index >= 0); @@ -166,25 +283,26 @@ function getRegionStartIndex(text, match) { } function stripMatchedRegion(addressText, match) { - const cityLabel = getDisplayCityLabel(match.city.label); let detailAddress = normalizeText(addressText); + const matchedProvince = findBestAliasInText(detailAddress, match.provinceAliases); + const matchedCity = findBestAliasInText(detailAddress, match.cityAliases); + const matchedDistrict = findBestAliasInText(detailAddress, match.districtAliases); - [match.province.label, cityLabel, match.district.label] + [matchedProvince, matchedCity, matchedDistrict] .filter(Boolean) .forEach((label) => { detailAddress = detailAddress.replace(label, ''); }); - return detailAddress.replace(/^[,,;;::\s]+/, '').trim(); + return cleanupAddressText(detailAddress); } function extractNameFromText(rawText = '', textWithoutPhone = '', regionStartIndex = -1) { - const sourceText = regionStartIndex > 0 - ? textWithoutPhone.slice(0, regionStartIndex) - : textWithoutPhone || rawText; + const sourceText = + regionStartIndex > 0 ? textWithoutPhone.slice(0, regionStartIndex) : textWithoutPhone || rawText; const directNameMatch = normalizeText(rawText).match( - /(?:收货人|收件人|联系人|姓名)\s*[::]?\s*([A-Za-z\u4e00-\u9fa5·]{2,20})/ + /(?:收货人|收件人|联系人|姓名)\s*[::]?\s*([A-Za-z\u4e00-\u9fa5·]{2,20})/u ); if (directNameMatch?.[1]) { return directNameMatch[1].trim(); @@ -196,7 +314,7 @@ function extractNameFromText(rawText = '', textWithoutPhone = '', regionStartInd .trim(); const tokens = cleaned.split(/\s+/).filter(Boolean); const nameToken = tokens.find((token) => { - return !ADDRESS_SIGNAL_REG.test(token) && /^[A-Za-z\u4e00-\u9fa5·]{2,20}$/.test(token); + return !ADDRESS_SIGNAL_REG.test(token) && /^[A-Za-z\u4e00-\u9fa5·]{2,20}$/u.test(token); }); return nameToken || ''; @@ -223,6 +341,78 @@ function formatRegionCodes(match) { return [match.province.value, match.city.value, match.district.value]; } +function combineDetailParts(parts = []) { + return parts + .map((item) => cleanupAddressText(item)) + .filter(Boolean) + .join(''); +} + +function extractFieldValue(text = '', labels = []) { + if (!text || !labels.length) { + return ''; + } + + const labelPattern = createLabelPattern(labels); + const fieldReg = new RegExp( + `(?:${labelPattern})\\s*[::]\\s*([\\s\\S]*?)(?=(?:[\\n,,]\\s*)?(?:${FIELD_LABEL_PATTERN})\\s*[::]|$)`, + 'u' + ); + const match = normalizeText(text).match(fieldReg); + + if (!match?.[1]) { + return ''; + } + + return normalizeText(match[1]).replace(/^[,,\s]+|[,,\s]+$/g, ''); +} + +function extractLabeledFields(text = '') { + return { + name: extractFieldValue(text, NAME_LABELS), + phone: extractFieldValue(text, PHONE_LABELS), + region: extractFieldValue(text, REGION_LABELS), + detail: extractFieldValue(text, DETAIL_LABELS) + }; +} + +function parseFieldBasedShippingText(text = '') { + const fields = extractLabeledFields(text); + const hasAnyField = Object.values(fields).some(Boolean); + + if (!hasAnyField) { + return null; + } + + const recipientName = cleanupName(fields.name); + const recipientPhone = extractPhone(fields.phone || text).phone; + const regionSource = normalizeText(fields.region); + const detailSource = normalizeText(fields.detail); + let regionMatch = findRegionMatch(regionSource || `${detailSource}${text}`); + + if (!regionMatch) { + regionMatch = findRegionMatch(text); + } + + const detailParts = []; + if (regionSource) { + const regionRemainder = regionMatch ? stripMatchedRegion(regionSource, regionMatch) : regionSource; + if (regionRemainder) { + detailParts.push(regionRemainder); + } + } + if (detailSource) { + detailParts.push(detailSource); + } + + return { + recipient_name: recipientName, + recipient_phone: recipientPhone, + regionCodes: regionMatch ? formatRegionCodes(regionMatch) : [], + detailAddress: combineDetailParts(detailParts) + }; +} + export function buildRegionColumns(regionCodes = []) { const provinceCode = getProvinceByCode(regionCodes[0])?.value || provinceOptions[0]?.value || ''; @@ -291,6 +481,11 @@ export function parseShippingText(rawText = '') { }; } + const fieldBasedResult = parseFieldBasedShippingText(text); + if (fieldBasedResult) { + return fieldBasedResult; + } + const { phone, textWithoutPhone } = extractPhone(text); const regionMatch = findRegionMatch(textWithoutPhone); const regionStartIndex = regionMatch ? getRegionStartIndex(textWithoutPhone, regionMatch) : -1;