Files
Bike-Tinder/reference/motorcycle-hunt-rank.py

232 lines
8.6 KiB
Python
Raw Permalink 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.
#!/usr/bin/env python3
"""
motorcycle-hunt-rank — score the current hunt board for Glenn's small-cruiser
search (NS + NB). Reads the state JSONs written by motorcycle-hunt-scan.py and
prints a ranked candidate list. Reusable on cron wakes.
Usage:
python3 motorcycle-hunt-rank.py [--top N] [--kind cruiser|style|all] [--src NAME]
Rubric (see the motorcycle-hunt skill for the full rationale):
base weight per model family (S = Indian Scout + Vulcan 900 Custom top tier,
T1 = strong fits, T2/style = wildcards), plus extras (MVI +6, whitewalls +5, windshield
/bags +3, spokes +3, ABS +2), mileage and year bonuses, price-fit vs the
budgets (used soft ~$5k / stretch ~$6.5k; new ~$10k / stretch ~$11.5k).
"""
import argparse
import glob
import html
import json
import os
import re
STATE = os.path.expanduser('~/.hermes/scripts/motorcycle-hunt.d')
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 load_sources():
out = {}
for f in sorted(glob.glob(os.path.join(STATE, '*.json'))):
name = os.path.basename(f)[:-5]
if name.endswith('.tmp'):
continue
try:
with open(f, encoding='utf-8') as fh:
out[name] = json.load(fh)
except Exception:
pass
return out
def km_num(it):
s = str(it.get('km') or '')
m = re.search(r'([\d,]+)', s)
return int(m.group(1).replace(',', '')) if m else None
def classify(title, desc):
text = (title + ' ' + desc).lower()
ttext = title.lower()
for pat, why in VETO:
if re.search(pat, ttext):
return None, None, 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, None, None, None
return best[2], best[1], best[3], None
def score_item(src, it):
title = it.get('title') or ''
desc = it.get('desc') or ''
kind, base, label, veto = classify(title, desc)
if not kind:
return None
flags = []
s = base
text = (title + ' ' + desc).lower()
for pat, pts, fl in EXTRAS:
if re.search(pat, text):
s += pts
flags.append(fl)
price = it.get('price_num')
if price:
is_new = (it.get('cond') == 'NEW' or it.get('offer') == 'N')
if is_new:
if price <= 11500:
s += 3
elif price <= 13500:
s += 1
else:
s -= 1
flags.append('new')
else:
if price <= 5000:
s += 5
elif price <= 6500:
s += 3
elif price <= 8000:
s += 1
elif price <= 10000:
s -= 1
else:
s -= 3
else:
flags.append('no price')
km = km_num(it)
if km == 0:
km = None # 0 km on a listing is a placeholder, not delivery mileage
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
flags.append('{}km'.format(km))
ym = re.search(r'\b(19\d\d|20[0-2]\d)\b', title)
year = int(ym.group(1)) if ym else None
if year:
if year >= 2020:
s += 2
elif year >= 2015:
s += 1
elif year <= 2005:
s -= 1
return {'src': src, 'id': it.get('id') or '', 'kind': kind, 'label': label,
'score': s, 'price': it.get('price') or '', 'km': km, 'year': year,
'title': title, 'place': it.get('loc') or it.get('city') or '',
'url': it.get('url') or '', 'flags': flags, 'desc': desc,
'first_seen': it.get('first_seen', ''), 'prev': it.get('price_prev')}
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--top', type=int, default=80)
ap.add_argument('--kind', default='all')
ap.add_argument('--src', default=None)
args = ap.parse_args()
sources = load_sources()
rows, counts = [], {}
for src, st in sources.items():
if args.src and src != args.src:
continue
for vid, it in (st.get('items') or {}).items():
it = dict(it)
it.setdefault('id', vid)
r = score_item(src, it)
if r is None:
continue
counts[r['kind']] = counts.get(r['kind'], 0) + 1
if args.kind != 'all' and r['kind'] != args.kind:
continue
rows.append(r)
rows.sort(key=lambda r: (-r['score'], r['price'] or '$999999'))
print('=== motorcycle-hunt rank | board:', {k: v for k, v in sorted(counts.items())},
'| shown:', min(len(rows), args.top), '===')
for r in rows[:args.top]:
desc = re.sub(r'\s+', ' ', r['desc'])[:105]
print('[{}] {} | {} | {} | {} | {} | {} | km={} | {}'.format(
r['kind'], r['score'], r['label'], r['src'], r['id'], r['price'],
r['title'][:64], r['km'] if r['km'] is not None else '-', r['url'][:95]))
if desc:
print(' desc: {}'.format(desc))
print('=== families unmatched (not shown): kinds =', counts, '===')
if __name__ == '__main__':
main()