diff --git a/app/rubric.py b/app/rubric.py new file mode 100644 index 0000000..5afbe09 --- /dev/null +++ b/app/rubric.py @@ -0,0 +1,152 @@ +"""Rubric scoring for Bike-Tinder, ported verbatim from the hunt's +reference/motorcycle-hunt-rank.py (family tiers + extras + veto + price fit). + +The hunt's ranker drops rows with no family match (returns None). Bike-Tinder +shows everything, so an unmatched/vetoed row scores 0 and sorts below scored +rows (docs/DATA-MODEL.md: "Unscored rows sort below scored ones; nothing is +hidden"). +""" +import re + +# (pattern, weight, tier, label) — copied from motorcycle-hunt-rank.py. +FAMILIES = [ + (r'\bscout\b', 92, 'S', 'Indian Scout — top priority (sport×cruise)'), + (r'vulcan\s*900\s*custom', 88, 'S', 'Vulcan 900 Custom — the dream spec'), + (r'vulcan\s*900', 62, 'T1', 'Vulcan 900'), + (r'vulcan\s*(classic|lt|nomad)', 58, 'T1', 'Vulcan Classic/LT'), + (r'vulcan\s*s\b', 50, 'T1', 'Vulcan S'), + (r'vulcan\s*500', 40, 'T2', 'Vulcan 500'), + (r'\bvulcan\b', 45, 'T1', 'Vulcan'), + (r'\bvolusia\b', 44, 'T1', 'Volusia (C50 predecessor)'), + (r'rebel\s*500', 50, 'T1', 'Rebel 500'), + (r'rebel\s*300', 44, 'T1', 'Rebel 300'), + (r'\brebel\b', 46, 'T1', 'Rebel'), + (r'boulevard\s*c50', 50, 'T1', 'Boulevard C50'), + (r'\bc50t?\b', 48, 'T1', 'Boulevard C50/C50T'), + (r'boulevard\s*(m50|s50)', 44, 'T1', 'Boulevard M50/S50'), + (r'boulevard\s*(c90|m90|m109)', 42, 'T2', 'Boulevard big twin'), + (r'm109r', 42, 'T2', 'Boulevard M109R'), + (r'\bboulevard\b', 40, 'T1', 'Boulevard'), + (r'\bintruder\b', 36, 'T2', 'Intruder'), + (r'shadow\s*(aero|phantom|spirit)', 48, 'T1', 'Shadow Aero/Phantom/Spirit'), + (r'shadow\s*750', 44, 'T1', 'Shadow 750'), + (r'\bshadow\b', 42, 'T1', 'Shadow'), + (r'\bsabre\b', 34, 'T2', 'Shadow Sabre'), + (r'v-?star\s*(650|950)', 46, 'T1', 'V-Star 650/950'), + (r'v-?star\s*1100', 40, 'T2', 'V-Star 1100'), + (r'v-?star', 42, 'T1', 'V-Star'), + (r'iron\s*883', 46, 'T1', 'Iron 883'), + (r'\bsportster\b', 40, 'T1', 'Sportster'), + (r'super\s*meteor', 48, 'T1', 'Super Meteor 650'), + (r'meteor\s*350', 42, 'T1', 'Meteor 350'), + (r'\bmeteor\b', 42, 'T1', 'Meteor'), + (r'hunter\s*350', 38, 'T2', 'Hunter 350'), + (r'classic\s*(350|650)', 36, 'T2', 'RE Classic'), + (r'royal\s*enfield', 34, 'T2', 'Royal Enfield'), + (r'\beliminator\b', 38, 'T2', 'Eliminator'), + (r'\bvirago\b', 34, 'T2', 'Virago'), + (r'\bmagna\b', 32, 'T2', 'Magna'), + (r'\bnightster\b', 32, 'T2', 'Nightster'), + (r'forty-?eight', 32, 'T2', 'Forty-Eight'), + (r'\bbonneville\b', 30, 'style', 'Bonneville (style-adjacent)'), + (r'\bw800\b', 28, 'style', 'W800'), + (r'z650rs', 28, 'style', 'Z650RS'), + (r'moto\s*guzzi|\bv7\b', 30, 'style', 'V7'), + (r'\bscrambler\b', 24, 'style', 'Scrambler'), + (r'\bcontinental\s*gt\b', 26, 'style', 'Continental GT'), +] + +# category vetoes — not a small cruiser (dirt/sport/touring/powersports/gear) +VETO = [ + (r'\b(atv|quad|side[\s-]?by[\s-]?side|utv|ranger|outlander|renegade|ryker|spyder|ski-doo|seadoo|sea-doo|snowmobile|snow\s*blower|lawn|generator|pump|watercraft|boat)\b', 'powersports'), + (r'\b(dirt\s*bike|motocross|enduro)\b', 'dirt'), + (r'\b(crf|kx\d|yz\d|rm-?z|klx|exc|xc-w|xc-f|fe\s*\d|fc\s*\d|mc\s*\d|sx\b|dr-?z|ttr|kdx)\b', 'dirt'), + (r'\b(ninja|zx-?\d|gsx-?[rs]|cbr|yzf|hayabusa|panigale|fireblade|gixxer|katana|r\d\s)\b', 'sport'), + (r'\b(v-?strom|versys|africa\s*twin|multistrada|t[eé]n[eé]r[eé]|transalp|super\s*adventure)\b', 'adv'), + (r'\b(gold\s*wing|voyager|electra\s*glide|road\s*king|ultra\s*classic|f6b)\b', 'tourer'), + (r'\b(helmet|half-?shell|jacket|glove|boots?\b|seat\b|exhaust|muffler|tire|tyre|rim\b|brake|lever|mirror|cover\b|battery|sprocket|for\s*parts|parts\b|project\s*bike|salvage|wreck)\b', 'gear'), +] + +EXTRAS = [ + (r'\b(mvi|safety|inspected|inspection|certified|e-?test)\b', 6, 'MVI/cert'), + (r'white\s*wall', 5, 'whitewalls'), + (r'\bspokes?\b|spoked', 3, 'spokes'), + (r'wind\s*screen|windshield', 3, 'windshield'), + (r'saddle\s*bags?|panniers?|\bbags\b', 3, 'bags'), + (r'\babs\b', 2, 'ABS'), +] + + +def km_num(km): + """Parse a km string like '32778' or '12,345' to int; None when absent.""" + s = str(km or '') + m = re.search(r'([\d,]+)', s) + return int(m.group(1).replace(',', '')) if m else None + + +def classify(title, desc): + """Return (tier, weight, label, veto_reason). Veto checks title only; + family match checks title + desc (mirrors the hunt ranker).""" + text = (title + ' ' + desc).lower() + ttext = title.lower() + for pat, why in VETO: + if re.search(pat, ttext): + return None, 0, None, why + best = None + for pat, weight, tier, label in FAMILIES: + if re.search(pat, text): + if best is None or weight > best[1]: + best = (pat, weight, tier, label) + if not best: + return None, 0, None, None + return best[2], best[1], best[3], None + + +def score_item(title, desc, price_num, km, year=None): + """Return (score, family_label). Unmatched/vetoed rows score 0.""" + if km == 0: + km = None # 0 km is a placeholder, not delivery mileage + tier, base, label, _veto = classify(title, desc) + if tier is None: + return 0, None + s = base + text = (title + ' ' + desc).lower() + for pat, pts, _fl in EXTRAS: + if re.search(pat, text): + s += pts + if price_num: + # Kijiji v1 carries no cond/offer field, so every row is treated as + # used (matches the hunt ranker's behaviour on these state files). + if price_num <= 5000: + s += 5 + elif price_num <= 6500: + s += 3 + elif price_num <= 8000: + s += 1 + elif price_num <= 10000: + s -= 1 + else: + s -= 3 + if km is not None: + if km < 5000: + s += 4 + elif km < 15000: + s += 2 + elif km > 90000: + s -= 5 + elif km > 60000: + s -= 3 + if year: + if year >= 2020: + s += 2 + elif year >= 2015: + s += 1 + elif year <= 2005: + s -= 1 + return s, label + + +def year_from_title(title): + """Extract a model year from the title, or None.""" + m = re.search(r'\b(19\d\d|20[0-2]\d)\b', title or '') + return int(m.group(1)) if m else None