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 numberPI1957201in a separate field 202AE WHITE O/U 12/28 BL/WD 3"Federal 308 150gr Fusion MSR Ammunition 20rds - F308MSR1Kimber CDS9 9mm Luger 3.18in 13rd/15rd BlackBarnaul 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:
-
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 XLis listed beforeP365. -
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.PI1957201decodes to Glock 19 Gen 3, which is the only way to identify theGlock 10 + 1 Round Double Action Only 9mmlisting above. -
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. -
An accessory filter.
Glock 19 holsterandMagpul PMAG for Glock 19must not become pistols, butGlock 19 Gen 5 9mm 15rd w/ 3 magazinesmust 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,307or6.5mmin 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.unescapeis 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)gives0.5312(ties go to even);toFixed(4)in JavaScript gives0.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
import { parseTitle } from "gunsnprices";
parseTitle("Glock 19 Gen 5 MOS 9mm 4.02in 15rd Pistol").model.model; // 'Glock 19 Gen 5 MOS'
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)