| 37337 |
amit |
1 |
package com.spice.profitmandi.web.offercircular;
|
|
|
2 |
|
|
|
3 |
import java.util.ArrayList;
|
|
|
4 |
import java.util.Arrays;
|
|
|
5 |
import java.util.LinkedHashSet;
|
|
|
6 |
import java.util.List;
|
|
|
7 |
import java.util.Set;
|
|
|
8 |
import java.util.regex.Matcher;
|
|
|
9 |
import java.util.regex.Pattern;
|
|
|
10 |
|
|
|
11 |
/**
|
|
|
12 |
* Splitting and normalising the "Eligible products" cell.
|
|
|
13 |
*
|
|
|
14 |
* This is the most error-prone part of the circular. Three behaviours here are
|
|
|
15 |
* load-bearing and were each arrived at by fixing a real mis-parse:
|
|
|
16 |
*
|
|
|
17 |
* 1. Never split on '/' blindly - it appears inside variant specs, so
|
|
|
18 |
* "Tab S11 Ultra Wifi (12/256GB)/Tab S11 Ultra Wifi (12/512GB)" would produce a
|
|
|
19 |
* phantom product literally called "256GB". Parenthesised groups are masked
|
|
|
20 |
* first and '/' between two digits is never a split point.
|
|
|
21 |
* 2. Capacities canonicalise to <n>X (GB) and <n>Y (TB), never <n>G, so the network
|
|
|
22 |
* marker in "Reno 16C 5G" cannot be mistaken for 5 GB of storage.
|
|
|
23 |
* 3. Model keys collapse spaces but keep digits significant, so "RENO16C" equals
|
|
|
24 |
* "Reno 16C 5G" while "iPhone 17e" never equals "iPhone 7".
|
|
|
25 |
*/
|
|
|
26 |
public final class ProductNames {
|
|
|
27 |
|
|
|
28 |
private ProductNames() { }
|
|
|
29 |
|
|
|
30 |
/** Brand names and the 5G/4G suffix are noise - the circular and the catalog
|
|
|
31 |
* disagree about whether to include them. WIFI/LTE/BT are NOT noise: for
|
|
|
32 |
* tablets and watches they are the only thing separating two real variants. */
|
|
|
33 |
private static final Set<String> NOISE = new LinkedHashSet<>(Arrays.asList(
|
|
|
34 |
"5G", "4G", "GALAXY", "TECNO", "ONEPLUS", "XIAOMI", "SAMSUNG", "APPLE",
|
|
|
35 |
"REALME", "VIVO", "OPPO", "MOTOROLA", "MOTO", "NOTHING", "GOOGLE",
|
|
|
36 |
"NEW", "WITH", "THE"));
|
|
|
37 |
|
|
|
38 |
private static final char MASK = '\u0001';
|
|
|
39 |
private static final char SLASH_KEEP = '\u0002';
|
|
|
40 |
|
|
|
41 |
private static final Pattern PARENS = Pattern.compile("\\([^)]*\\)");
|
|
|
42 |
private static final Pattern DIGIT_SLASH_DIGIT = Pattern.compile("(?<=\\d)\\s*/\\s*(?=\\d)");
|
|
|
43 |
private static final Pattern MASK_THEN_LETTER =
|
|
|
44 |
Pattern.compile("(" + MASK + "\\d+" + MASK + ")(?=[A-Za-z])");
|
|
|
45 |
/** Two variant groups run straight together, e.g. "Edge 70 Pro (8+256)(12+256)".
|
|
|
46 |
* These are two variants of one model, not one product with four memory numbers.
|
|
|
47 |
* Left joined, the tokens merged and the offer bound to a single arbitrary SKU
|
|
|
48 |
* while the other variant silently got nothing - the same money-lands-on-the-
|
|
|
49 |
* wrong-SKU failure as the missing-unit bug. Only ADJACENT groups qualify: a '/'
|
|
|
50 |
* between them, as in "X300 FE(12+512G)/(12+256G)", is already a split point. */
|
|
|
51 |
private static final Pattern MASK_THEN_MASK =
|
|
|
52 |
Pattern.compile("(" + MASK + "\\d+" + MASK + ")(?=" + MASK + ")");
|
|
|
53 |
private static final Pattern SPLIT = Pattern.compile(
|
|
|
54 |
"\\s*(?:,|/|&(?!\\s*(?:ENCO|BUBBLE|ACCESS|EXT))|\\bAND\\b)\\s*",
|
|
|
55 |
Pattern.CASE_INSENSITIVE);
|
|
|
56 |
private static final Pattern VARIANT_ONLY =
|
|
|
57 |
Pattern.compile("^[\\d\\s+/]*(?:GB|TB|G|T)?[\\d\\s+/GBT]*$", Pattern.CASE_INSENSITIVE);
|
|
|
58 |
private static final Pattern STORE_PROSE = Pattern.compile(
|
|
|
59 |
"\\(?\\s*(offer applicable on all stores except|only for)\\b.*$",
|
|
|
60 |
Pattern.CASE_INSENSITIVE);
|
|
|
61 |
private static final Pattern TRAILING_PAIR = Pattern.compile(
|
|
|
62 |
"\\s*\\(?\\s*\\d+\\s*[+/]\\s*\\d+\\s*(?:GB|TB|G|T)?\\s*\\)?\\s*$",
|
|
|
63 |
Pattern.CASE_INSENSITIVE);
|
|
|
64 |
private static final Pattern TRAILING_SINGLE = Pattern.compile(
|
|
|
65 |
"\\s*\\(?\\s*\\d+\\s*(?:GB|TB)\\s*\\)?\\s*$", Pattern.CASE_INSENSITIVE);
|
|
|
66 |
private static final Pattern HAS_LETTER = Pattern.compile("[A-Za-z]");
|
|
|
67 |
|
|
|
68 |
/*
|
|
|
69 |
* A '+'-joined accessory bundle - "RENO16C 8+256GB +Bubble",
|
|
|
70 |
* "X300 Pro(16+512G)+Extender" - is deliberately NOT stripped down to the phone.
|
|
|
71 |
*
|
|
|
72 |
* Stripping it looks tempting: it would resolve 30 CATALOG_GAP rows and light up
|
|
|
73 |
* ten otherwise-dark offers. It was tried and reverted, because on Aug'26 vivo pays
|
|
|
74 |
* a HIGHER cap for the bundle than for the bare phone - X300 Pro(16+512G) is capped
|
|
|
75 |
* at Rs.10,000 on its own row and Rs.11,000 on the "+Extender" row, the difference
|
|
|
76 |
* being the Extender itself. Merging them lets the bundle's cap be claimed on a
|
|
|
77 |
* phone sold without the accessory. (Oppo's bundles happen to carry identical
|
|
|
78 |
* values, which is what made the merge look safe until vivo was checked.)
|
|
|
79 |
*
|
|
|
80 |
* Whether a bundle offer transfers to the bare SKU is a commercial question the PDF
|
|
|
81 |
* does not answer, so it belongs in manual curation as a coverage decision, not in
|
|
|
82 |
* the parser.
|
|
|
83 |
*/
|
|
|
84 |
|
|
|
85 |
private static final Pattern GB = Pattern.compile("(\\d+)\\s*GB", Pattern.CASE_INSENSITIVE);
|
|
|
86 |
private static final Pattern TB = Pattern.compile("(\\d+)\\s*TB", Pattern.CASE_INSENSITIVE);
|
|
|
87 |
/** "a+b" / "a/b" is ALWAYS RAM + storage, with or without a unit on either side. */
|
|
|
88 |
private static final Pattern MEMORY_PAIR = Pattern.compile(
|
|
|
89 |
"\\b(\\d+)\\s*(?:GB|G)?\\s*[+/]\\s*(\\d+)\\s*(GB|TB|G|T)?", Pattern.CASE_INSENSITIVE);
|
|
|
90 |
/** Leading \b is essential: without it the "5X" inside the model name "A5X"
|
|
|
91 |
* reads as 5 GB, which both invents a phantom capacity and reduces the model key
|
|
|
92 |
* to "A" - making Oppo A5X and A6X indistinguishable. */
|
|
|
93 |
private static final Pattern CAPACITY = Pattern.compile("\\b\\d+[XY]\\b");
|
|
|
94 |
/** Same \b requirement as MEMORY_PAIR and CAPACITY: a digit glued to letters
|
|
|
95 |
* belongs to the model name. Without it "S25+ 5G" had its "25+ 5" stripped as a
|
|
|
96 |
* pair, keying Samsung S25+ as "SG". */
|
|
|
97 |
private static final Pattern PAIR_ANY = Pattern.compile(
|
|
|
98 |
"\\(?\\s*\\b\\d+\\s*[XY]?\\s*[+/]\\s*\\d+\\s*[XY]?\\s*\\)?");
|
|
|
99 |
private static final Pattern NON_ALNUM = Pattern.compile("[^A-Z0-9 ]");
|
|
|
100 |
|
|
|
101 |
/**
|
|
|
102 |
* Loose normalisation used for alias keys and for fuzzy similarity.
|
|
|
103 |
*
|
|
|
104 |
* Deliberately NOT the same as the capacity canonicalisation used by modelKey:
|
|
|
105 |
* here GB/TB collapse to G/T, which is fine because the result is only ever
|
|
|
106 |
* compared against another string put through the same function.
|
|
|
107 |
*/
|
|
|
108 |
public static String norm(String text) {
|
|
|
109 |
String s = text.toUpperCase().replace('+', ' ').replace('/', ' ');
|
|
|
110 |
s = GB.matcher(s).replaceAll("$1G");
|
|
|
111 |
s = TB.matcher(s).replaceAll("$1T");
|
|
|
112 |
s = NON_ALNUM.matcher(s).replaceAll(" ");
|
|
|
113 |
return s.trim().replaceAll("\\s+", " ");
|
|
|
114 |
}
|
|
|
115 |
|
|
|
116 |
/** Splits a multi-product cell, inheriting the model name for variant-only parts. */
|
|
|
117 |
public static List<String> split(String cell) {
|
|
|
118 |
List<String> out = new ArrayList<>();
|
|
|
119 |
if (cell == null || cell.trim().isEmpty()) {
|
|
|
120 |
return out;
|
|
|
121 |
}
|
|
|
122 |
List<String> masks = new ArrayList<>();
|
|
|
123 |
StringBuffer masked = new StringBuffer();
|
|
|
124 |
Matcher paren = PARENS.matcher(cell);
|
|
|
125 |
while (paren.find()) {
|
|
|
126 |
masks.add(paren.group());
|
|
|
127 |
paren.appendReplacement(masked,
|
|
|
128 |
Matcher.quoteReplacement(MASK + String.valueOf(masks.size() - 1) + MASK));
|
|
|
129 |
}
|
|
|
130 |
paren.appendTail(masked);
|
|
|
131 |
|
|
|
132 |
String text = DIGIT_SLASH_DIGIT.matcher(masked).replaceAll(String.valueOf(SLASH_KEEP));
|
|
|
133 |
// two products run together with no delimiter, e.g.
|
|
|
134 |
// "A37 5G (8GB/128GB)A37 5G (12GB/256GB)" - break after a masked group
|
|
|
135 |
text = MASK_THEN_LETTER.matcher(text).replaceAll("$1,");
|
|
|
136 |
// ...and two variant groups run together, "Edge 70 Pro (8+256)(12+256)"
|
|
|
137 |
text = MASK_THEN_MASK.matcher(text).replaceAll("$1,");
|
|
|
138 |
|
|
|
139 |
String lastModel = null;
|
|
|
140 |
for (String part : SPLIT.split(text)) {
|
|
|
141 |
String p = part.replace(SLASH_KEEP, '/');
|
|
|
142 |
for (int i = 0; i < masks.size(); i++) {
|
|
|
143 |
p = p.replace(MASK + String.valueOf(i) + MASK, masks.get(i));
|
|
|
144 |
}
|
|
|
145 |
p = STORE_PROSE.matcher(p).replaceAll("");
|
|
|
146 |
p = strip(p, " .");
|
|
|
147 |
if (p.isEmpty() || "NA".equalsIgnoreCase(p) || "N/A".equalsIgnoreCase(p)
|
|
|
148 |
|| "-".equals(p)) {
|
|
|
149 |
continue;
|
|
|
150 |
}
|
|
|
151 |
String bare = strip(p, "() ");
|
|
|
152 |
if (VARIANT_ONLY.matcher(bare).matches() && lastModel != null) {
|
|
|
153 |
out.add(lastModel + " " + bare); // "RENO16C 12+256GB, 8+256GB"
|
|
|
154 |
continue;
|
|
|
155 |
}
|
|
|
156 |
out.add(p);
|
|
|
157 |
// remember the model WITHOUT its variant, so the next bare "8+256GB" or
|
|
|
158 |
// "(512GB)" attaches to the model rather than to a full variant string
|
|
|
159 |
String base = TRAILING_PAIR.matcher(p).replaceAll("");
|
|
|
160 |
base = strip(TRAILING_SINGLE.matcher(base).replaceAll(""), " .(");
|
|
|
161 |
if (!base.isEmpty() && HAS_LETTER.matcher(base).find()) {
|
|
|
162 |
lastModel = base;
|
|
|
163 |
}
|
|
|
164 |
}
|
|
|
165 |
return out;
|
|
|
166 |
}
|
|
|
167 |
|
|
|
168 |
/**
|
|
|
169 |
* Capacities become <n>X (GB) / <n>Y (TB) so the network marker in
|
|
|
170 |
* "Reno 16C 5G" can never read as 5 GB of storage.
|
|
|
171 |
*
|
|
|
172 |
* The OEMs use three notations for the same thing and ALL must be recognised,
|
|
|
173 |
* because whether memory was mentioned decides variant-level versus model-level
|
|
|
174 |
* matching:
|
|
|
175 |
* Oppo "12+256GB" explicit unit
|
|
|
176 |
* vivo "(8+256G)" single-letter unit
|
|
|
177 |
* Motorola "(8+256)" no unit at all
|
|
|
178 |
* Missing any of these makes the matcher believe no size was given and bind the
|
|
|
179 |
* offer to an arbitrary sibling - which put a ₹2,000 Edge 60 Pro 12+256 offer and
|
|
|
180 |
* a ₹1,000 8+256 offer on the same SKU.
|
|
|
181 |
*
|
|
|
182 |
* A PAIR is unambiguous: "a+b" or "a/b" is always RAM + storage, whatever the
|
|
|
183 |
* unit, so both numbers are capacities. A STANDALONE number is only a capacity
|
|
|
184 |
* when it carries an explicit GB/TB - a bare "5G" or "4G" is a network marker.
|
|
|
185 |
*
|
|
|
186 |
* The leading \b is essential. Without it "S25+ 5G" matches as the pair "25 + 5",
|
|
|
187 |
* eating the 25 out of the model name S25 and the 5 out of the network marker 5G,
|
|
|
188 |
* which keyed S25+ as "S25X". A digit glued to letters is part of the name, never
|
|
|
189 |
* a capacity.
|
|
|
190 |
*/
|
|
|
191 |
private static String canonUnits(String text) {
|
|
|
192 |
String s = text.toUpperCase();
|
|
|
193 |
|
|
|
194 |
// pairs first: the second number's unit decides TB vs GB, the first is RAM in GB
|
|
|
195 |
Matcher pair = MEMORY_PAIR.matcher(s);
|
|
|
196 |
StringBuffer out = new StringBuffer();
|
|
|
197 |
while (pair.find()) {
|
|
|
198 |
String unit = pair.group(3);
|
|
|
199 |
String storageUnit = (unit != null && unit.startsWith("T")) ? "Y" : "X";
|
|
|
200 |
String replacement = pair.group(1) + "X " + pair.group(2) + storageUnit;
|
|
|
201 |
pair.appendReplacement(out, Matcher.quoteReplacement(replacement));
|
|
|
202 |
}
|
|
|
203 |
pair.appendTail(out);
|
|
|
204 |
s = out.toString();
|
|
|
205 |
|
|
|
206 |
// then standalone capacities, which require an explicit unit
|
|
|
207 |
s = GB.matcher(s).replaceAll("$1X");
|
|
|
208 |
s = TB.matcher(s).replaceAll("$1Y");
|
|
|
209 |
return s;
|
|
|
210 |
}
|
|
|
211 |
|
|
|
212 |
/** RAM/storage tokens, order-insensitive: "12+256GB" -> {12X, 256X} */
|
|
|
213 |
public static Set<String> variantTokens(String text) {
|
|
|
214 |
Set<String> tokens = new LinkedHashSet<>();
|
|
|
215 |
Matcher m = CAPACITY.matcher(canonUnits(text));
|
|
|
216 |
while (m.find()) {
|
|
|
217 |
tokens.add(m.group());
|
|
|
218 |
}
|
|
|
219 |
return tokens;
|
|
|
220 |
}
|
|
|
221 |
|
|
|
222 |
private static String stripVariant(String text) {
|
|
|
223 |
String s = canonUnits(text);
|
|
|
224 |
s = PAIR_ANY.matcher(s).replaceAll(" ");
|
|
|
225 |
s = CAPACITY.matcher(s).replaceAll(" ");
|
|
|
226 |
return s;
|
|
|
227 |
}
|
|
|
228 |
|
|
|
229 |
/**
|
|
|
230 |
* Spaces are insignificant, digits are significant. "RENO16C" and "Reno 16C 5G"
|
|
|
231 |
* both key to RENO16C; "iPhone 17e" keys to IPHONE17E and can never collide with
|
|
|
232 |
* "iPhone 7" (IPHONE7).
|
|
|
233 |
*/
|
|
|
234 |
public static String modelKey(String text) {
|
|
|
235 |
// A '+' surviving this far is part of the MODEL NAME, not a memory separator:
|
|
|
236 |
// canonUnits has already consumed "8+256" into capacity tokens. It must be
|
|
|
237 |
// preserved as a word, because "Realme 16 Pro" and "Realme 16 Pro+" are
|
|
|
238 |
// different phones at different prices - stripping it as punctuation collapsed
|
|
|
239 |
// them to the same key and cross-matched 6 SKUs across 12 offers.
|
|
|
240 |
String s = NON_ALNUM.matcher(stripVariant(text).replace("+", " PLUS "))
|
|
|
241 |
.replaceAll(" ");
|
|
|
242 |
StringBuilder key = new StringBuilder();
|
|
|
243 |
for (String word : s.trim().split("\\s+")) {
|
|
|
244 |
if (!word.isEmpty() && !NOISE.contains(word)) {
|
|
|
245 |
key.append(word);
|
|
|
246 |
}
|
|
|
247 |
}
|
|
|
248 |
return key.toString();
|
|
|
249 |
}
|
|
|
250 |
|
|
|
251 |
/** Python's str.strip(chars) - trims any of the given characters from both ends. */
|
|
|
252 |
private static String strip(String s, String chars) {
|
|
|
253 |
int from = 0;
|
|
|
254 |
int to = s.length();
|
|
|
255 |
while (from < to && chars.indexOf(s.charAt(from)) >= 0) { from++; }
|
|
|
256 |
while (to > from && chars.indexOf(s.charAt(to - 1)) >= 0) { to--; }
|
|
|
257 |
return s.substring(from, to);
|
|
|
258 |
}
|
|
|
259 |
}
|