Files
LWLT-AIBOT/chrome-extension/ltjt-order-assistant/product-lookup.js
2026-09-02 19:29:04 +08:00

168 lines
6.5 KiB
JavaScript

(function installLTJTProductLookup(root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (root) root.LTJTProductLookup = api;
}(typeof self !== 'undefined' ? self : globalThis, () => {
'use strict';
function compactProductLookupText(value) {
return String(value || '')
.normalize('NFKC')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '');
}
function normalizeProductQueryText(value) {
// ERP products commonly write duration as 8D7N while users may write
// 8天7晚. Only convert duration suffixes after a number so a product name
// containing an ordinary Chinese character such as “天” is not changed.
return compactProductLookupText(value)
.replace(/(\d+)天/gu, '$1d')
.replace(/(\d+)(?:晚|夜)/gu, '$1n');
}
function normalizeProductDuration(value) {
return normalizeProductQueryText(value);
}
function isOrderedSubsequence(query, value) {
let cursor = 0;
for (const character of query) {
const next = value.indexOf(character, cursor);
if (next < 0) return false;
cursor = next + character.length;
}
return true;
}
function collapseWhitespace(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function rawCellText(element) {
return String(element?.innerText || element?.textContent || '').trim();
}
function productLabelFromRow(row) {
return collapseWhitespace(rawCellText(row?.cells?.[1] || row));
}
function productNameAndDuration(label) {
const normalizedLabel = collapseWhitespace(label);
// The current ERP renders the product name and duration in the same
// second cell, separated by whitespace (for example “江西老挝 10D9N”).
// Restrict the suffix parser to duration-shaped text so names containing
// dates or numbers such as “尊享老挝3.10” remain intact.
const durationMatch = normalizedLabel.match(/(?:^|\s)(\d+(?:\.\d+)?\s*(?:D|天)\s*\d+(?:\.\d+)?\s*(?:N|晚|夜)?)(?:\s*)$/iu);
if (!durationMatch) return { name: normalizedLabel, duration: '' };
const name = normalizedLabel.slice(0, durationMatch.index).trim();
return {
name: name || normalizedLabel,
duration: collapseWhitespace(durationMatch[1]).replace(/\s+/g, '')
};
}
function nativeProductSearchQueries(query) {
const keyword = collapseWhitespace(query);
if (!keyword) return [''];
// S_chanpinming is a contiguous product-name LIKE filter; duration is a
// separate ERP field. Preserve a complete product name verbatim, but trim
// a trailing Arabic duration shorthand before issuing the native query.
// Keep one bounded empty-query fallback for legacy shorthand whose name
// fragments are not contiguous in the ERP product name.
const productNameQuery = keyword
.replace(/\s*\d+(?:\.\d+)?\s*(?:D|天)(?:\s*\d+(?:\.\d+)?\s*(?:N|晚|夜))?\s*$/iu, '')
.trim();
return productNameQuery ? [productNameQuery, ''] : [''];
}
function productCandidateFromRow(row, input = null) {
const label = productLabelFromRow(row);
const parts = productNameAndDuration(label);
return {
input,
row,
text: collapseWhitespace(rawCellText(row)),
label,
name: parts.name,
duration: parts.duration,
cpid: String(input?.value || '').trim()
};
}
function productQueryTokens(query) {
const normalizedQuery = normalizeProductQueryText(query);
const hanRuns = normalizedQuery.match(/\p{Script=Han}+/gu) || [];
const nonHanTokens = normalizedQuery.match(/\d+[a-z]+|[a-z]+\d*|\d+/g) || [];
const durationTokens = nonHanTokens.filter((token) => /\d+d|\d+n/i.test(token));
const nameTokens = nonHanTokens.filter((token) => !durationTokens.includes(token));
return { normalizedQuery, hanRuns, durationTokens, nameTokens };
}
function productQueryMatch(candidateOrLabel, query) {
const candidate = typeof candidateOrLabel === 'string'
? { label: candidateOrLabel, name: candidateOrLabel, duration: '' }
: (candidateOrLabel || {});
const normalizedLabel = compactProductLookupText(candidate.label || candidate.name || '');
const normalizedQuery = normalizeProductQueryText(query);
if (!normalizedQuery) return { matched: true, rule: 'empty_keyword' };
if (normalizedLabel.includes(normalizedQuery)) return { matched: true, rule: 'unique_contiguous' };
const { hanRuns, durationTokens, nameTokens } = productQueryTokens(query);
if (!hanRuns.length && !durationTokens.length && !nameTokens.length) {
return { matched: false, rule: 'no_tokens' };
}
const normalizedName = compactProductLookupText(candidate.name || candidate.label || '');
const normalizedDuration = normalizeProductDuration(candidate.duration || '');
if (hanRuns.some((run) => !isOrderedSubsequence(run, normalizedName))) {
return { matched: false, rule: 'name_ordered_tokens' };
}
if (durationTokens.some((token) => !normalizedDuration.includes(token) && !normalizedLabel.includes(token))) {
return { matched: false, rule: 'duration_tokens' };
}
if (nameTokens.some((token) => !normalizedName.includes(token) && !normalizedLabel.includes(token))) {
return { matched: false, rule: 'name_numeric_tokens' };
}
return {
matched: true,
rule: durationTokens.length ? 'unique_name_duration_tokens' : 'unique_ordered_tokens'
};
}
function collectProductCandidates(scopeDocument) {
return Array.from(scopeDocument?.querySelectorAll?.('input[name="cp_id"], input[name="cpid"]') || [])
.map((input) => {
const row = input.closest('tr') || input.parentElement?.parentElement || input.parentElement;
return productCandidateFromRow(row, input);
});
}
function matchProductCandidates(candidates, query, cpid = '') {
const expectedCpid = String(cpid || '').trim();
return (candidates || [])
.filter((candidate) => !expectedCpid || String(candidate.cpid || '').trim() === expectedCpid)
.map((candidate) => ({
...candidate,
match: productQueryMatch(candidate, query)
}))
.filter((candidate) => candidate.match.matched);
}
return {
compactProductLookupText,
normalizeProductQueryText,
normalizeProductDuration,
isOrderedSubsequence,
productLabelFromRow,
productNameAndDuration,
nativeProductSearchQueries,
productCandidateFromRow,
productQueryTokens,
productQueryMatch,
collectProductCandidates,
matchProductCandidates
};
}));