Dev.to WebDev 🛠 Dev 👁 0 📖 4 min read

How I built a keyword research tool that can't see anyone's search history

I'm a 4th year CS student. Every time I had an idea for a blog post or a side project, I wanted to answer one question first: does anyone actually search for this? The tools that answer it well cost money. Ahrefs Lite i

I'm a 4th year CS student. Every time I had an idea for a blog post or a side project, I wanted to answer one question first: does anyone actually search for this?

The tools that answer it well cost money. Ahrefs Lite is $129 a month. Semrush Pro is about $140. And what bothered me more than the price was this: thousands of bloggers, niche site builders and small shops each pay, separately, for roughly the same data.

So I started building OpenKeyword, around one idea: if enough of us share the searches we already make, we can build that data together, and nobody has to pay for it.

That idea only works if sharing is safe. This post is about how I built it so the system forgets who shared what. It opens on 20 November at openkeyword.org.

What the tool does
You type a keyword and get:

a popularity score from 0 to 100
the search intent: how-to, best, vs, question, or other
a 12-month trend
up to 50 related ideas, sorted by score
It's free.

The keyword list grows from a Chrome extension. If you turn sharing on, it shares the words you search on search engines. That's where the privacy work is.

The rules I set before writing code
I wrote these down first and treated them as a contract every part of the system has to follow:

No search history per person, anywhere. Not in a database, not in a log.
Sensitive topics never leave the device.
IP addresses are never stored. Only a keyed hash, and only for rate limits, for at most 48 hours.
Raw data never leaves the server. The public site only sees the published copy.
The architecture
Chrome extension
| local queue, every word waits 30 min, sent in batches of max 50
v
Cloudflare Worker (validate, normalize, HMAC token per keyword)
v
Cloudflare Queue
v Cloudflare Tunnel, locked with Access service tokens
server: small API + Postgres 16 (raw data stays here)
| hourly sync of public columns only
v
Cloudflare D1 <--- website + public API read from here
The website and API run on Cloudflare.

  1. On the device: drop first, ask later The extension does nothing until you turn sharing on. The search-site permissions are optional and only requested at that moment.

When it sees a search, it normalizes the words and runs them through a filter on your computer. Anything that looks like health, adult, legal, money trouble or a people lookup is dropped right there. A small part of the list:

const SENSITIVE_TERMS = [
// health
"hiv", "cancer", "depression", "pregnan*", "symptom*", "diagnos*",
// legal / crime
"arrest*", "lawyer", "divorce", "warrant", "mugshot*",
// finance distress / identity
"bankrupt*", "debt", "payday", "ssn", "passport",
// people lookup
"who is", "address of", "phone number", "background check",
// ...
];
Validation also rejects URLs, emails, phone-like numbers, any run of 6+ digits and search operators like site:. A search for a person's phone number shouldn't become a "keyword".

Then there's timing. If every search were sent the moment it happened, the server would see a live stream of one person's browsing. So every word waits at least 30 minutes on its own clock, in a local queue the user can open and edit. An alarm runs every 5 minutes with 0-5 minutes of random jitter and sends whatever has waited long enough, in batches of up to 50.

  1. The Worker: tokens instead of identities Each install generates 32 random bytes on the client. That install key never gets stored on the server.

When a batch arrives, the Worker re-validates every keyword (never trust the client) and turns each one into a token:

/** dedupe token = hex(HMAC-SHA256(CONTRIB_SECRET, install_key|keyword_norm|country|lang)) */
export function dedupeToken(env: Env, installKey: string, keywordNorm: string, country: string, lang: string) {
return hmacHex(env.CONTRIB_SECRET, ${installKey}|${keywordNorm}|${country}|${lang});
}
That gives two properties:

The same person searching the same keyword twice produces the same token, so they count once. You can't push a keyword public by searching it 50 times.
The same person searching two different keywords produces two unrelated tokens. There's no column that ties "running shoes" and "cheap flights rome" to the same person. That's how rule 1 holds.
The contributions table in Postgres is literally (keyword, country, lang, token, first_seen_day). There is no install column to join on.

One subtle leak I had to close: contributors get a small credit count for their contributions. If the credit message and the contribution message arrived together, timing alone could pair them. So the credit goes out as a separate queue message with a random 60-600 second delay.

  1. The queue and the tunnel
    The Worker puts everything on a Cloudflare Queue. The consumer posts batches to my server through a Cloudflare Tunnel, protected by Cloudflare Access service tokens, so the API isn't open to the internet.

  2. Postgres : the 5-person rule
    Every keyword has a status: seeded, candidate, published or hidden. A contributed keyword starts as a candidate and is invisible. An hourly job counts distinct tokens per keyword, and once a keyword reaches 5, it's scored and promoted to published.

  3. Sync back to the edge
    Every hour, the home server pushes only public columns (keyword, score, bucket, intent, trend, contributor count) to Cloudflare D1. Postgres has a view called public_keywords, and the sync user can only read that view. The raw signals and the contributions table never leave the house.

The website and API read only from D1, so they're fast and keep working even when my server is offline. Related ideas use SQLite FTS5 in D1 with a prefix match on each word.

OpenKeyword opens on 20 November. If you want one email when it does, you can pre-register at openkeyword.org.

I'd love feedback on the design, especially from anyone who can find a hole in the token scheme. That's the part I most want to get right.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.