DEV Community

GunsNPrices
GunsNPrices

Posted on

What 64,000 gun store listings taught us about parsing product titles

If you compare prices across stores, sooner or later you have to answer a boring question: are these two listings the
same product? For guns and ammunition the answer lives almost entirely in the title, and store titles are a mess.

At GunsNPrices we track listings from 40+ US gun and ammo stores. We recently open-sourced the
parser we use to read those titles, as a Python package, a JavaScript package and an MCP server for AI assistants. This
post is about what we learned from running it on 63,755 real listings.

What store titles look like

A few real examples:

  • G19 GEN3 COMPACT 9MM 4" 2/15RD Thrasher
  • Glock 10 + 1 Round Double Action Only 9mm W/fixed Sights & O, with the part number PI1957201 in a separate field
  • 202AE WHITE O/U 12/28 BL/WD 3"
  • Federal 308 150gr Fusion MSR Ammunition 20rds - F308MSR1
  • Kimber CDS9 9mm Luger 3.18in 13rd/15rd Black
  • Barnaul 7.62x39 123gr FMJ 20/25

Every one of them is readable to a person who knows guns, and every one hides a trap for a regex.

O/U 12/28 BL/WD 3" is a 12 gauge over/under shotgun with a 28" barrel and a 3" chamber. BL/WD means blued metal and
walnut wood. The 3" is not the barrel length. 20/25 in an ammo title means 20 rounds per box and 25 boxes per case,
not a caliber. 13rd/15rd means the pistol ships with two magazines, and you probably want 15. 10/22 is a Ruger rifle,
not 10 gauge with a 22" barrel. And G19 GEN3 COMPACT 9MM 4" 2/15RD, given the brand from the store's feed, should come
out as a Glock 19 Gen 3 in 9mm with a 4" barrel and 15-round magazines (two of them).

The approach: dictionaries, not one big model

We tried the obvious things first. A general NER model doesn't know that G19 is a Glock and P365 is a Sig. An LLM
knows, but it costs money on every listing, it is slow, and it gives slightly different answers for the same title.

What works is boring and explicit:

  1. One dictionary per brand. Each brand (50 so far: Glock, Sig Sauer, Smith & Wesson, Ruger, Springfield, Beretta, Taurus...) has an ordered list of model patterns, generations, configurations and features. The first matching pattern wins, so P365 XL is listed before P365.
  2. A numeric mode for brands like Glock, where the model is a number with suffixes (19, 19X, 43X, 20SF) and the part number encodes the model and generation. PI1957201 decodes to Glock 19 Gen 3, which is the only way to identify the Glock 10 + 1 Round Double Action Only 9mm listing above.
  3. A ranking step when several dictionaries match. The listing's own brand wins, then a brand at the start of the title, then the brand mentioned first. This matters for titles like Kel tec Sub 2000 Defender 9mm 15rd Fits Glock Magazines.
  4. An accessory filter. Glock 19 holster and Magpul PMAG for Glock 19 must not become pistols, but Glock 19 Gen 5 9mm 15rd w/ 3 magazines must stay one. The filter only fires when the title has no gun words and the accessory isn't "included". On our firearm listings it rejects 4 real guns out of 32,316.

These are the same dictionaries the site uses to group listings, so the library returns the model names you see on
the site.

Calibers: spellings come from the stores

Caliber names are the other half of the problem. .300 AAC Blackout, 300 BLK, 300 Blackout, 300BO and 300 AAC
are one cartridge. 6.5 CM, 6.5 Creed, 6.5CR and 6.5 Creedmoor are another.

Instead of writing synonyms by hand, we generated them from what stores actually write: every caliber spelling seen in
store caliber fields, expanded with common abbreviations (win/winchester, mag/magnum, spl/special). Two rules
kept precision high:

  • Ambiguous spellings are resolved by popularity. If one spelling maps to two calibers, we keep it only when one has 10x more listings than the other. Otherwise the spelling is dropped.
  • Bare numbers are banned by default. 20, 307 or 6.5mm in a title is usually something else. A short allowlist (9mm, 380, .308, 243, 5.56...) made it in only after each number proved right in at least 97% of the listings where it appeared.

Context rules handle the rest: 12g next to CO2 is a 12-gram CO2 cartridge, not 12 gauge.

How accurate is it?

We measured against the attributes stored on GunsNPrices for 50,202 firearm and 13,555 ammo listings:

What Found in the title Right when found
Model (32,317 listings with a known model) 99.97% 99.8% same name as the site
Caliber, firearms 81% 97.8%
Caliber, ammo 91% 94.9%
Magazine capacity 46% 95.6%
Barrel length 58% 94.4%
Rounds per box 80% 96.6%

The "found" column is limited by the titles themselves: plenty of stores never put the caliber or barrel length in the
title.

The most interesting part was the "wrong" column. When we checked mismatches by hand, most of them were errors in the
store data, not in the parser. Accuracy International rifles titled AXSA 6 Creedmoor were stored as 6.5 Creedmoor. A 1000 Rounds case was
stored as a box of 20. A 16ga shotgun was labeled 12 gauge. The parser, which only reads the title, was right.

Keeping Python and JavaScript identical

We ship the parser twice: Python on PyPI and
JavaScript on npm. Both run a shared file of 1,150 recorded titles and
answers in their test suites, and on the full 63,755 titles they return exactly the same result.

Getting there took three small fixes worth knowing about if you ever port text-processing code:

  • JavaScript objects reorder integer-like keys. A dictionary of configurations like {"MK4": ..., "100": ...} comes back with "100" first. When the order of rules matters, store them as arrays of pairs.
  • html.unescape is not just & and ". Python accepts legacy entities without a semicolon and maps ” to a curly quote. We exported Python's entity table to get identical behavior.
  • Rounding ties. Python's round(0.53125, 4) gives 0.5312 (ties go to even); toFixed(4) in JavaScript gives 0.5313. One listing out of 63,755 hit this.

Using it

from gunsnprices import parse_title

info = parse_title("Federal American Eagle 5.56x45mm 55gr FMJ 1000 Rounds", price=449.99)
info.kind            # 'ammo'
info.caliber.name    # '5.56×45mm NATO'
info.rounds          # 1000
info.cost_per_round  # 0.45
Enter fullscreen mode Exit fullscreen mode
import { parseTitle } from "gunsnprices";

parseTitle("Glock 19 Gen 5 MOS 9mm 4.02in 15rd Pistol").model.model; // 'Glock 19 Gen 5 MOS'
Enter fullscreen mode Exit fullscreen mode

There is also an MCP server (npx -y gunsnprices-mcp) that gives AI
assistants like Claude and Cursor the same tools, plus links to the GunsNPrices pages where current prices are.

The model dictionaries and the caliber list are published as open datasets under CC BY 4.0 on
Hugging Face, Kaggle and
GitHub.

What's next

More brands, a price index of ammo cost per round by caliber built from daily prices, and a public API if there is
interest. Issues and corrections are welcome on GitHub. If you just want to know where
a gun or a box of ammo is cheapest today, that's what gunsnprices.com is for.

Top comments (0)