Files
Bike-Tinder/app/rubric.py
Torres 02a6b6df7e app: P2 migrations (year/seller_type/extras) + importer backfill, guards idempotent
- db.init_db gains guarded ALTER TABLE additions for listings.year /
  seller_type / extras (schema.sql v1 untouched; PRAGMA-checked idempotent)
- rubric: extras_from_text() returns matched EXTRAS labels (table order)
- importer: normalize computes year (existing helper), seller_type ONLY
  from a real state-item signal (dealer/private, else NULL), extras JSON;
  upsert rewrites the new columns on both paths
- verified on live DB: import idempotent, counts stay 640/560/80, year
  backfilled on 599 rows, extras on 45, seller_type 0 (no state signal)
2026-11-10 21:13:40 -04:00

163 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 extras_from_text(title, desc):
"""Return the labels of every EXTRAS pattern that matches title+desc.
One entry per label (labels are unique in the EXTRAS table); order is
the table order. Stored on listings.extras at import (P2).
"""
text = (title + ' ' + desc).lower()
return [label for pat, _pts, label in EXTRAS if re.search(pat, text)]
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