Prize scams run on scripts. The wording changes, but the skeleton doesn't: you won something, you were chosen, act fast, and — the load-bearing step — pay a small fee or hand over a code to "release" your prize. Regulators like the FTC count the losses from scams that start with a message or a social post in the billions, and most of those losses begin with text a person could have been warned about in the ten seconds before they tapped.
We built two small checkers around that observation: one scores a pasted message, the other dissects a link. Both are pure client-side pattern checks. This post is about how the scoring works, where it deliberately stops, and what it returns on real inputs — including the ones that make it look bad.
Design constraints first
Three decisions shaped everything else:
- Pure functions, no DOM, no network.
smcAnalyze(message, link)andlrcAnalyze(url)take strings and return{ score, tells, level, levelLabel }. Nothing is fetched — critically, the link checker parses a URL with theURLconstructor but never opens it. A tool for inspecting malicious links that visits malicious links would be a bad tool. - Every point must be explainable. Each hit appends a plain-language "tell" ("It asks you to PAY something…"), and the UI lists them. A risk score the user can't interrogate is just a coloured light.
- Three levels, carefully named:
Looks safer,Be careful,High risk — likely a scam pattern. There is no "Safe" anywhere in the code. That wording choice is a feature, discussed below.
The message model: weighted tells and two decisive flags
The message checker lowercases the message plus an optional link, then runs a table of regex checks through one helper:
function hit(re, label, weight, isStrong) { if (re.test(text)) { tells.push(label); score += weight; if (isStrong) strong = true; } } The weights, in order of appearance:
| Pattern | Weight | Decisive |
|---|---|---|
| Prize / winner / lottery claim | 2 | – |
| "You were (randomly) selected" | 2 | – |
| Payment ask: release/processing/delivery fee, gift cards, crypto, wire transfer | 3 | ✔ |
| Credential ask: password, OTP, PIN, bank or card details, CVV | 3 | ✔ |
| Shortened link (bit.ly, t.co, rb.gy, …) | 2 | – |
| Urgency or threats ("act now", "account will be closed") | 1 | – |
| Official-sounding impersonation words / "Dear customer" | 1 | – |
| Pressure to move to WhatsApp/Telegram or reply instantly | 1 | – |
The level logic is one line of consequence:
if (strong || score >= 5) { level = 2; } // High risk else if (score >= 2) { level = 1; } // Be careful else { level = 0; } // Looks safer The strong flag exists because addition alone gets the ethics wrong. A message whose only sin is asking for a "release fee" scores just 3 — below the high-risk threshold — yet "pay to receive your prize" is the single most reliable scam signature there is. So payment asks and credential asks are decisive: either one forces High risk on its own, regardless of total.
The link model: read the host, not the story
The link checker normalises the input (prepending https:// for parsing if no scheme was pasted), then works almost entirely on the hostname:
- IP-literal host or a
:in the host — 3, decisive. Real banks don't send login links as raw numbers. -
@in the authority — 3, decisive. Everything before the@is discarded by the browser; it's a hiding trick. - Brand name in the wrong host — 3, decisive. A small table maps 15 brands to their real domains (
paypal → paypal.com,amazon → amazon.com, DHL, Netflix, Binance…). The rule is exact:
if (host.indexOf(brand) !== -1 && host !== real && host.slice(-(real.length + 1)) !== '.' + real) { hit('"paypal" appears in the address, but this is NOT the real paypal.com', 3, true); } Containing the brand name proves nothing; being the domain or a subdomain of it is the only pass.
- Supporting signals: plain
http://(2), a known URL shortener (2), three or more stacked subdomains (2), punycodexn--(2), the word "bank" in an unlisted domain (2), no visible scheme (1), length over 75 characters (1), three or more hyphens (1), five or more digits (1).
Worked examples — actual outputs
These are the functions' real results (the logic has no DOM dependencies, so it runs unchanged under Node):
| Input | Score | Verdict |
|---|---|---|
| "Congratulations! You have won a $1000 cash prize. You were randomly selected… pay a small release fee of $25 by gift card within 24 hours. Dear customer, act now — contact us on WhatsApp…" | 10 (6 tells) | 🚩 High risk |
| "Hi Sam, your parcel is on its way and should arrive Thursday. You can follow it in the app any time." | 0 | ✅ Looks safer |
"Your order has shipped. Track your parcel here:" + https://bit.ly/3xYzAbc | 2 (shortener only) | ⚠️ Be careful |
http://192.168.1.55/paypal-login | 6 (http 2 + IP 3 + digit count 1) | 🚩 High risk |
https://www.amazon.com | 0 | ✅ Looks safer |
paypal-verify-account.com | 4 (no scheme 1 + brand mismatch 3) | 🚩 High risk |
The last row is the design in miniature: the score alone (4) sits in "Be careful" territory, but the decisive brand rule overrides — exactly the case a pure threshold would have under-called.
The part we refused to rig
Shortener lists are where a tool like this can quietly cheat, so we didn't: t.co is on both checkers' shortener lists. That's the shortener used by X — and the one used for our own shared links. Paste one of our own shortened links into the Link Checker and it comes back "Be careful: the real destination is hidden until you click." That is, inconveniently, true — for everyone's links, including ours. A warning tool that exempts its author isn't a warning tool.
The same stance produced the rest of the honesty surface:
- Every result carries the note: "This is a pattern check, not a verdict on this specific sender or website."
- The green state says "Looks safer", never "Safe" — a formulaic scam written carefully enough can score low, and the UI shouldn't claim otherwise.
- The link checker explicitly defers to Google Safe Browsing and VirusTotal for live checks, because shape analysis cannot see what a page actually does.
- Empty or unparseable input returns "Be careful" with an explanation — never a pass, never a crash.
What this approach can't do matters as much: regexes are evadable, honest messages trip tells (a genuine delivery text with a shortened tracking link is "Be careful" — by design, because the reader genuinely can't see where it leads), and no pattern score can verify a sender. It's a warning light, not a judge. But warning lights are cheap, instant, and private — and for the ten seconds before someone pays a "release fee," they're often the only check that happens at all.
Both tools run live as working demos — the Scam Message Checker and the companion Link Checker — as pure page snippets: no server, no database, nothing you paste leaves the browser.