Blame | Last modification | View Log | RSS feed
package com.smartdukaan.cron.offercircular;import java.util.ArrayList;import java.util.Arrays;import java.util.LinkedHashSet;import java.util.List;import java.util.Set;import java.util.regex.Matcher;import java.util.regex.Pattern;/*** Splitting and normalising the "Eligible products" cell.** This is the most error-prone part of the circular. Three behaviours here are* load-bearing and were each arrived at by fixing a real mis-parse:** 1. Never split on '/' blindly - it appears inside variant specs, so* "Tab S11 Ultra Wifi (12/256GB)/Tab S11 Ultra Wifi (12/512GB)" would produce a* phantom product literally called "256GB". Parenthesised groups are masked* first and '/' between two digits is never a split point.* 2. Capacities canonicalise to <n>X (GB) and <n>Y (TB), never <n>G, so the network* marker in "Reno 16C 5G" cannot be mistaken for 5 GB of storage.* 3. Model keys collapse spaces but keep digits significant, so "RENO16C" equals* "Reno 16C 5G" while "iPhone 17e" never equals "iPhone 7".*/public final class ProductNames {private ProductNames() { }/** Brand names and the 5G/4G suffix are noise - the circular and the catalog* disagree about whether to include them. WIFI/LTE/BT are NOT noise: for* tablets and watches they are the only thing separating two real variants. */private static final Set<String> NOISE = new LinkedHashSet<>(Arrays.asList("5G", "4G", "GALAXY", "TECNO", "ONEPLUS", "XIAOMI", "SAMSUNG", "APPLE","REALME", "VIVO", "OPPO", "MOTOROLA", "MOTO", "NOTHING", "GOOGLE","NEW", "WITH", "THE"));private static final char MASK = '\u0001';private static final char SLASH_KEEP = '\u0002';private static final Pattern PARENS = Pattern.compile("\\([^)]*\\)");private static final Pattern DIGIT_SLASH_DIGIT = Pattern.compile("(?<=\\d)\\s*/\\s*(?=\\d)");private static final Pattern MASK_THEN_LETTER =Pattern.compile("(" + MASK + "\\d+" + MASK + ")(?=[A-Za-z])");/** Two variant groups run straight together, e.g. "Edge 70 Pro (8+256)(12+256)".* These are two variants of one model, not one product with four memory numbers.* Left joined, the tokens merged and the offer bound to a single arbitrary SKU* while the other variant silently got nothing - the same money-lands-on-the-* wrong-SKU failure as the missing-unit bug. Only ADJACENT groups qualify: a '/'* between them, as in "X300 FE(12+512G)/(12+256G)", is already a split point. */private static final Pattern MASK_THEN_MASK =Pattern.compile("(" + MASK + "\\d+" + MASK + ")(?=" + MASK + ")");private static final Pattern SPLIT = Pattern.compile("\\s*(?:,|/|&(?!\\s*(?:ENCO|BUBBLE|ACCESS|EXT))|\\bAND\\b)\\s*",Pattern.CASE_INSENSITIVE);private static final Pattern VARIANT_ONLY =Pattern.compile("^[\\d\\s+/]*(?:GB|TB|G|T)?[\\d\\s+/GBT]*$", Pattern.CASE_INSENSITIVE);private static final Pattern STORE_PROSE = Pattern.compile("\\(?\\s*(offer applicable on all stores except|only for)\\b.*$",Pattern.CASE_INSENSITIVE);private static final Pattern TRAILING_PAIR = Pattern.compile("\\s*\\(?\\s*\\d+\\s*[+/]\\s*\\d+\\s*(?:GB|TB|G|T)?\\s*\\)?\\s*$",Pattern.CASE_INSENSITIVE);private static final Pattern TRAILING_SINGLE = Pattern.compile("\\s*\\(?\\s*\\d+\\s*(?:GB|TB)\\s*\\)?\\s*$", Pattern.CASE_INSENSITIVE);private static final Pattern HAS_LETTER = Pattern.compile("[A-Za-z]");/** A '+'-joined accessory bundle - "RENO16C 8+256GB +Bubble",* "X300 Pro(16+512G)+Extender" - is deliberately NOT stripped down to the phone.** Stripping it looks tempting: it would resolve 30 CATALOG_GAP rows and light up* ten otherwise-dark offers. It was tried and reverted, because on Aug'26 vivo pays* a HIGHER cap for the bundle than for the bare phone - X300 Pro(16+512G) is capped* at Rs.10,000 on its own row and Rs.11,000 on the "+Extender" row, the difference* being the Extender itself. Merging them lets the bundle's cap be claimed on a* phone sold without the accessory. (Oppo's bundles happen to carry identical* values, which is what made the merge look safe until vivo was checked.)** Whether a bundle offer transfers to the bare SKU is a commercial question the PDF* does not answer, so it belongs in manual curation as a coverage decision, not in* the parser.*/private static final Pattern GB = Pattern.compile("(\\d+)\\s*GB", Pattern.CASE_INSENSITIVE);private static final Pattern TB = Pattern.compile("(\\d+)\\s*TB", Pattern.CASE_INSENSITIVE);/** "a+b" / "a/b" is ALWAYS RAM + storage, with or without a unit on either side. */private static final Pattern MEMORY_PAIR = Pattern.compile("\\b(\\d+)\\s*(?:GB|G)?\\s*[+/]\\s*(\\d+)\\s*(GB|TB|G|T)?", Pattern.CASE_INSENSITIVE);/** Leading \b is essential: without it the "5X" inside the model name "A5X"* reads as 5 GB, which both invents a phantom capacity and reduces the model key* to "A" - making Oppo A5X and A6X indistinguishable. */private static final Pattern CAPACITY = Pattern.compile("\\b\\d+[XY]\\b");/** Same \b requirement as MEMORY_PAIR and CAPACITY: a digit glued to letters* belongs to the model name. Without it "S25+ 5G" had its "25+ 5" stripped as a* pair, keying Samsung S25+ as "SG". */private static final Pattern PAIR_ANY = Pattern.compile("\\(?\\s*\\b\\d+\\s*[XY]?\\s*[+/]\\s*\\d+\\s*[XY]?\\s*\\)?");private static final Pattern NON_ALNUM = Pattern.compile("[^A-Z0-9 ]");/*** Loose normalisation used for alias keys and for fuzzy similarity.** Deliberately NOT the same as the capacity canonicalisation used by modelKey:* here GB/TB collapse to G/T, which is fine because the result is only ever* compared against another string put through the same function.*/public static String norm(String text) {String s = text.toUpperCase().replace('+', ' ').replace('/', ' ');s = GB.matcher(s).replaceAll("$1G");s = TB.matcher(s).replaceAll("$1T");s = NON_ALNUM.matcher(s).replaceAll(" ");return s.trim().replaceAll("\\s+", " ");}/** Splits a multi-product cell, inheriting the model name for variant-only parts. */public static List<String> split(String cell) {List<String> out = new ArrayList<>();if (cell == null || cell.trim().isEmpty()) {return out;}List<String> masks = new ArrayList<>();StringBuffer masked = new StringBuffer();Matcher paren = PARENS.matcher(cell);while (paren.find()) {masks.add(paren.group());paren.appendReplacement(masked,Matcher.quoteReplacement(MASK + String.valueOf(masks.size() - 1) + MASK));}paren.appendTail(masked);String text = DIGIT_SLASH_DIGIT.matcher(masked).replaceAll(String.valueOf(SLASH_KEEP));// two products run together with no delimiter, e.g.// "A37 5G (8GB/128GB)A37 5G (12GB/256GB)" - break after a masked grouptext = MASK_THEN_LETTER.matcher(text).replaceAll("$1,");// ...and two variant groups run together, "Edge 70 Pro (8+256)(12+256)"text = MASK_THEN_MASK.matcher(text).replaceAll("$1,");String lastModel = null;for (String part : SPLIT.split(text)) {String p = part.replace(SLASH_KEEP, '/');for (int i = 0; i < masks.size(); i++) {p = p.replace(MASK + String.valueOf(i) + MASK, masks.get(i));}p = STORE_PROSE.matcher(p).replaceAll("");p = strip(p, " .");if (p.isEmpty() || "NA".equalsIgnoreCase(p) || "N/A".equalsIgnoreCase(p)|| "-".equals(p)) {continue;}String bare = strip(p, "() ");if (VARIANT_ONLY.matcher(bare).matches() && lastModel != null) {out.add(lastModel + " " + bare); // "RENO16C 12+256GB, 8+256GB"continue;}out.add(p);// remember the model WITHOUT its variant, so the next bare "8+256GB" or// "(512GB)" attaches to the model rather than to a full variant stringString base = TRAILING_PAIR.matcher(p).replaceAll("");base = strip(TRAILING_SINGLE.matcher(base).replaceAll(""), " .(");if (!base.isEmpty() && HAS_LETTER.matcher(base).find()) {lastModel = base;}}return out;}/*** Capacities become <n>X (GB) / <n>Y (TB) so the network marker in* "Reno 16C 5G" can never read as 5 GB of storage.** The OEMs use three notations for the same thing and ALL must be recognised,* because whether memory was mentioned decides variant-level versus model-level* matching:* Oppo "12+256GB" explicit unit* vivo "(8+256G)" single-letter unit* Motorola "(8+256)" no unit at all* Missing any of these makes the matcher believe no size was given and bind the* offer to an arbitrary sibling - which put a ₹2,000 Edge 60 Pro 12+256 offer and* a ₹1,000 8+256 offer on the same SKU.** A PAIR is unambiguous: "a+b" or "a/b" is always RAM + storage, whatever the* unit, so both numbers are capacities. A STANDALONE number is only a capacity* when it carries an explicit GB/TB - a bare "5G" or "4G" is a network marker.** The leading \b is essential. Without it "S25+ 5G" matches as the pair "25 + 5",* eating the 25 out of the model name S25 and the 5 out of the network marker 5G,* which keyed S25+ as "S25X". A digit glued to letters is part of the name, never* a capacity.*/private static String canonUnits(String text) {String s = text.toUpperCase();// pairs first: the second number's unit decides TB vs GB, the first is RAM in GBMatcher pair = MEMORY_PAIR.matcher(s);StringBuffer out = new StringBuffer();while (pair.find()) {String unit = pair.group(3);String storageUnit = (unit != null && unit.startsWith("T")) ? "Y" : "X";String replacement = pair.group(1) + "X " + pair.group(2) + storageUnit;pair.appendReplacement(out, Matcher.quoteReplacement(replacement));}pair.appendTail(out);s = out.toString();// then standalone capacities, which require an explicit units = GB.matcher(s).replaceAll("$1X");s = TB.matcher(s).replaceAll("$1Y");return s;}/** RAM/storage tokens, order-insensitive: "12+256GB" -> {12X, 256X} */public static Set<String> variantTokens(String text) {Set<String> tokens = new LinkedHashSet<>();Matcher m = CAPACITY.matcher(canonUnits(text));while (m.find()) {tokens.add(m.group());}return tokens;}private static String stripVariant(String text) {String s = canonUnits(text);s = PAIR_ANY.matcher(s).replaceAll(" ");s = CAPACITY.matcher(s).replaceAll(" ");return s;}/*** Spaces are insignificant, digits are significant. "RENO16C" and "Reno 16C 5G"* both key to RENO16C; "iPhone 17e" keys to IPHONE17E and can never collide with* "iPhone 7" (IPHONE7).*/public static String modelKey(String text) {// A '+' surviving this far is part of the MODEL NAME, not a memory separator:// canonUnits has already consumed "8+256" into capacity tokens. It must be// preserved as a word, because "Realme 16 Pro" and "Realme 16 Pro+" are// different phones at different prices - stripping it as punctuation collapsed// them to the same key and cross-matched 6 SKUs across 12 offers.String s = NON_ALNUM.matcher(stripVariant(text).replace("+", " PLUS ")).replaceAll(" ");StringBuilder key = new StringBuilder();for (String word : s.trim().split("\\s+")) {if (!word.isEmpty() && !NOISE.contains(word)) {key.append(word);}}return key.toString();}/** Python's str.strip(chars) - trims any of the given characters from both ends. */private static String strip(String s, String chars) {int from = 0;int to = s.length();while (from < to && chars.indexOf(s.charAt(from)) >= 0) { from++; }while (to > from && chars.indexOf(s.charAt(to - 1)) >= 0) { to--; }return s.substring(from, to);}}