232 lines
8.6 KiB
Python
232 lines
8.6 KiB
Python
#!/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()
|