app: port hunt rubric scoring (family tiers, extras, veto, price fit)
This commit is contained in:
152
app/rubric.py
Normal file
152
app/rubric.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user