DEV Community

chris
chris

Posted on

I built a landing page grader with zero backend (here are the heuristics)

Everyone can now smell AI copy. "Revolutionize your workflow." "Seamlessly unlock powerful solutions." "Elevate your experience." The moment a stranger reads that on your hero section, they file you under generated and bounce.

So I built a tool that scores it for you: paste a headline, a subhead and a button label, get a 0–100 number for how generic/AI-templated the copy reads — plus the exact lines to fix.

The interesting constraint: no LLM, no backend, no network call. The whole thing is deterministic and runs in your browser. Your text never leaves the page. That's not a privacy gimmick — it's the point. To detect "sounds AI-generated" you don't need AI. You need a list of tells and a scoring function.

Try it: 1h-money-store.vercel.app/grader

Here's every heuristic, and the code.

The model: 5 dimensions, 100 points

I split "does this read human?" into five measurable dimensions:

Dimension Max What it measures
Anti-hype 25 Buzzwords, exclamation marks, emoji, ALL-CAPS
Specificity 25 Is there a concrete number / proof?
Clarity 25 Filler-word density
Headline shape 13 Word count of the headline
CTA 12 Is the button generic ("Submit") or specific?

Each dimension starts at its max and loses points for tells. Sum them, that's the score. No black box — you can trace every point.

1. Anti-hype (25 pts) — the #1 tell

Hype words are the single strongest "AI wrote this" signal. LLMs reach for them by default because they're statistically safe and say nothing. I keep a wordlist and penalize each hit hard.

var HYPE = ['revolutionize','revolutionary','unlock','unleash','seamless',
  'game-changer','cutting-edge','next-level','supercharge','effortless',
  'elevate','empower','transform','best-in-class','world-class',
  'state-of-the-art','robust','synergy','disruptive','innovative','leverage',
  'harness','turbocharge','skyrocket','10x','paradigm','frictionless','holistic'];

// count whole-word hits, case-insensitive, respecting word boundaries
function countHits(t, list){
  var l = ' ' + t.toLowerCase() + ' ', n = 0;
  list.forEach(function(w){
    var re = new RegExp('(^|[^a-z])' + w.replace(/[-\/\\^$*+?.()|[\]{}]/g,'\\$&') + '([^a-z]|$)','g');
    var m = l.match(re);
    if (m) n += m.length;
  });
  return n;
}
Enter fullscreen mode Exit fullscreen mode

Then hype isn't the only shouting tell. Exclamation marks, emoji in a hero headline, and ALL-CAPS words all read as templated. So the penalty stacks:

var hype = countHits(all, HYPE);
var excl = (all.match(/!/g) || []).length;
var emo  = emojiCount(all);
var caps = words(h + ' ' + s).filter(function(w){
  return w.length > 2 && w === w.toUpperCase() && /[A-Z]/.test(w);
}).length;

var antiPen = hype*7 + excl*5 + emo*4 + caps*4;
var antiHype = Math.max(0, 25 - antiPen);
Enter fullscreen mode Exit fullscreen mode

Weights are opinionated: one hype word (−7) costs more than one exclamation (−5). A single "revolutionize" can tank this whole dimension, which is exactly the behavior I want.

2. Specificity (25 pts) — one number changes everything

Generic copy floats. "We help teams do their best work." Specific copy lands. "Cut standups from 30 minutes to 6." The cheapest signal of specificity is a digit. So:

function hasNumber(t){ return /\d/.test(t); }

var spec = hasNumber(all) ? 25 : 8;
// partial credit for quantifier words even without a digit
if (/%|\bx\b|×|hours?|days?|minutes?|\bno\b|zero/i.test(all) && spec < 25)
  spec = Math.min(25, spec + 9);
Enter fullscreen mode Exit fullscreen mode

No number at all = you start at 8/25. This is intentionally blunt: a number isn't sufficient for good copy, but its absence is a reliable smell.

3. Clarity (25 pts) — filler tax

Second wordlist: the vague nouns and intensifiers that add length, not meaning. "Solutions." "Platform." "Powerful." "Simply just really very."

var FILLER = ['solutions','platform','powerful','amazing','great','awesome',
  'stuff','things','simply','just','very','really','stunning','beautiful',
  'ultimate','premium','quality','value','experience','journey','ecosystem',
  'suite','toolkit','all-in-one','one-stop'];

var filler = countHits(all, FILLER);
var clarity = Math.max(0, 25 - filler*6);
Enter fullscreen mode Exit fullscreen mode

Same countHits machine, −6 per hit. Four filler words zeroes the dimension.

4. Headline shape (13 pts) — length is a proxy

You can't measure "is this a good headline" without a model, but you can measure length, and length correlates with clarity at the extremes. Too long = you crammed two ideas in. Too short = it's probably vague.

var hw = words(h).length, hlScore = 13;
if (hw === 0)       hlScore = 0;
else if (hw > 12)   hlScore = 5;   // rambling
else if (hw > 10)   hlScore = 9;
else if (hw < 3)    hlScore = 7;   // too thin to carry the offer
// sweet spot 3–10 words keeps full marks
Enter fullscreen mode Exit fullscreen mode

5. CTA (12 pts) — "Submit" is a wasted button

Last wordlist: dead button labels. If your CTA is in it, you're leaving the most-clicked element on the page saying nothing.

var WEAKCTA = ['submit','learn more','click here','read more','get started',
  'sign up','continue','next','go','here','more info','discover','explore'];

var ctaW = c.trim().toLowerCase();
var weak = WEAKCTA.indexOf(ctaW) >= 0 || ctaW === '';
var ctaScore = c.trim() === '' ? 0 : (weak ? 4 : 12);
Enter fullscreen mode Exit fullscreen mode

"Start a project", "Grade my page", "Get the 42 prompts" → full marks. "Learn more" → 4.

Turning the score into fixes

A number alone is a party trick. The value is telling you which line to change. Every penalty that fired becomes a concrete instruction:

var fixes = [];
if (hype > 0) fixes.push({t:'Cut the hype words',
  d:'Found ' + hype + '. Replace each with a plain, concrete verb. Hype words are the #1 AI tell.'});
if (!hasNumber(all)) fixes.push({t:'Add one number',
  d:'No concrete figure anywhere. A single %, count or timeframe raises believability instantly.'});
if (filler > 0) fixes.push({t:'Delete filler',
  d:'Found ' + filler + ' vague words. They add length, not meaning.'});
if (hw > 12) fixes.push({t:'Shorten the headline',
  d:hw + ' words is too long. Aim for ≤10 — the single idea a stranger would repeat.'});
if (weak && c.trim() !== '') fixes.push({t:'Rewrite the CTA',
  d:'Use action + outcome, not "Submit/Learn more".'});
Enter fullscreen mode Exit fullscreen mode

The output reads back the count (Found 3) so the feedback is falsifiable — you can go find the three words.

Why deterministic beats an LLM here

I could have shipped this as a prompt to GPT/Claude. I didn't, and I'd argue you shouldn't for this class of tool:

  • Same input → same score, forever. No temperature, no drift. You can screenshot a score and it'll reproduce.
  • Zero cost, zero latency, zero infra. It's a static HTML file on a CDN. No API key to rotate, no rate limit, no bill.
  • Privacy is structural, not promised. The text physically can't leave — there's no fetch. That's a stronger claim than any privacy policy.
  • It's honest about what it is. It's a checklist of known tells, not an oracle. Users can read the rules and disagree with a weight — which is the right relationship to have with a linter.

The whole grader is ~90 lines of vanilla JS in one file. No framework, no build step.

Try it / take the wordlists

Paste your own hero copy and see what it flags: the Landing Page Grader — free, no signup, runs entirely client-side.

If you want the flip side — prompts that force ChatGPT/Claude to stop producing the words on these lists — I packaged 7 of them free here: 1h-money-store.vercel.app/free.

What tells would you add to the wordlists? I'm collecting them.

Top comments (0)