# How OpenSEO Detects Project Targets: Domain vs. Keyword Classification Explained

> Discover how OpenSEO's detectTarget function classifies project inputs as domains or keywords using simple heuristics like whitespace and dot presence for efficient target detection.

- Repository: [Every App/open-seo](https://github.com/every-app/open-seo)
- Tags: deep-dive
- Published: 2026-08-09

---

**OpenSEO uses a deterministic heuristic in the `detectTarget` function to classify user input as either a domain or keyword by checking for whitespace absence, dot presence, and valid URL normalisation.**

Every SEO project in OpenSEO starts with a target—either a website you want to track or a keyword you want to monitor. The system must correctly interpret this input to route data to the appropriate API endpoints (DataForSEO for domains, brand-lookup queries for keywords). This article explains how OpenSEO implements target detection based on the source code in `every-app/open-seo`.

## The Target Detection Pipeline

The core logic lives in [`src/shared/targetDetection.ts`](https://github.com/every-app/open-seo/blob/main/src/shared/targetDetection.ts). The `detectTarget` function follows a strict five-step pipeline to categorise input without external dependencies or machine learning.

### Step 1: Sanitise Input

Raw strings are trimmed of leading and trailing whitespace to ensure consistent processing.

```typescript
const trimmed = rawInput.trim();

```

This prevents accidental misclassification from copy-paste artifacts like trailing spaces.

### Step 2: Apply Domain Heuristics

The code performs a lightweight check to flag potential domains. Input qualifies for deeper validation only if it:

- Contains no whitespace characters
- Includes at least one dot (`.`)

```typescript
const looksLikeDomain = trimmed.length > 0 && !/\s/.test(trimmed) && trimmed.includes(".");

```

This filter quickly eliminates obvious keywords like "OpenAI" or "content marketing" while preserving candidates like "example.com" or "subdomain.example.co.uk".

### Step 3: Normalise Valid Domains

When heuristic checks pass, `detectTarget` delegates to `normalizeDomain` from [`src/types/schemas/domain.ts`](https://github.com/every-app/open-seo/blob/main/src/types/schemas/domain.ts). This helper:

1. Prepends `https://` if no protocol exists
2. Parses the string using the native `URL` class
3. Strips `www.` prefixes and lower-cases the hostname
4. Throws on malformed inputs (invalid protocols, unparseable strings)

```typescript
const hostname = normalizeDomain(trimmed);

```

The normalisation ensures canonical storage—`HTTPS://WWW.Example.COM/` becomes `example.com`.

### Step 4: Confirm Domain Structure

After normalisation, the function verifies the hostname still contains a dot, confirming a valid domain structure rather than a single-segment hostname like `localhost`.

```typescript
if (hostname.includes(".")) {
  return { type: "domain", value: hostname };
}

```

### Step 5: Fallback to Keyword Classification

Any failure—whitespace detection, missing dot, or `normalizeDomain` exception—triggers keyword classification. The original trimmed string is preserved to maintain user intent.

```typescript
return { type: "keyword", value: trimmed };

```

## Practical Implementation Examples

### Basic Usage

```typescript
import { detectTarget } from "@/shared/targetDetection";

// Bare domain
detectTarget("example.com");
// → { type: "domain", value: "example.com" }

// Full URL with protocol and path
detectTarget("https://www.example.com/features");
// → { type: "domain", value: "example.com" }

// Keyword with surrounding whitespace
detectTarget("  Example Brand  ");
// → { type: "keyword", value: "Example Brand" }

// Sentence containing a domain (keyword intent)
detectTarget("Visit example.com today");
// → { type: "keyword", value: "Visit example.com today" }

```

### Project Creation Integration

In the actual project workflow, `detectTarget` determines how downstream services process the target:

```typescript
function createProject(rawTarget: string) {
  const target = detectTarget(rawTarget);
  
  // Stores: { type: "domain" | "keyword", value: string }
  const project = await db.projects.create({
    targetType: target.type,
    targetValue: target.value,
  });
  
  // Later: DataForSEO hostname queries for domains,
  // brand-lookup API for keywords
}

```

## Where Target Detection Integrates

OpenSEO relies on consistent target classification across multiple features:

- **Share-of-Voice analysis** — `resolveCompetitorGroups` in [`src/server/features/ai-search/services/shareOfVoice.ts`](https://github.com/every-app/open-seo/blob/main/src/server/features/ai-search/services/shareOfVoice.ts) deduplicates competitor entries using `detectTarget` results, preventing duplicate tracking of the same domain entered in different formats.

- **Brand Lookup** — Both the server service ([`brandLookup.ts`](https://github.com/every-app/open-seo/blob/main/brandLookup.ts)) and the UI component ([`BrandLookupPage.tsx`](https://github.com/every-app/open-seo/blob/main/BrandLookupPage.tsx)) normalise primary targets and competitors before API submission.

- **Project management** — All project creation and edit flows pass targets through this pipeline to ensure canonical storage and correct API routing.

## Key Source Files

| File | Responsibility |
|------|---------------|
| [`src/shared/targetDetection.ts`](https://github.com/every-app/open-seo/blob/main/src/shared/targetDetection.ts) | Core heuristic implementation with five-step pipeline |
| [`src/shared/targetDetection.test.ts`](https://github.com/every-app/open-seo/blob/main/src/shared/targetDetection.test.ts) | Unit tests for domains, keywords, edge cases, and error handling |
| [`src/types/schemas/domain.ts`](https://github.com/every-app/open-seo/blob/main/src/types/schemas/domain.ts) | `normalizeDomain` helper for URL parsing and canonicalisation |
| [`src/server/features/ai-search/services/shareOfVoice.ts`](https://github.com/every-app/open-seo/blob/main/src/server/features/ai-search/services/shareOfVoice.ts) | Competitor deduplication using detection results |
| [`src/server/features/ai-search/services/brandLookup.ts`](https://github.com/every-app/open-seo/blob/main/src/server/features/ai-search/services/brandLookup.ts) | Brand lookup service integration |
| [`src/client/features/ai-search/BrandLookupPage.tsx`](https://github.com/every-app/open-seo/blob/main/src/client/features/ai-search/BrandLookupPage.tsx) | UI component calling detection before server submission |

## Summary

- OpenSEO target detection is **deterministic and synchronous**—no external calls or ML inference required.
- The **dot-and-whitespace heuristic** filters most keywords before expensive URL parsing occurs.
- **`normalizeDomain`** enforces canonical form: lowercase, no `www`, validated hostname structure.
- **Fallback-to-keyword design** ensures user input is never lost, even for edge cases.
- Detection results drive **API endpoint selection** (DataForSEO hostnames vs. brand-lookup queries) across project creation, competitor analysis, and brand monitoring features.

## Frequently Asked Questions

### What happens if a user enters "localhost" as a project target?

`detectTarget` initially flags "localhost" as a domain candidate (no spaces, contains no dot initially—actually "localhost" has no dot, so it fails the heuristic). Wait: correcting—"localhost" has no dot, so `looksLikeDomain` evaluates false at step 2, immediately falling through to keyword classification. If a user somehow passed a dot-containing localhost variant like "localhost.localdomain", it would reach `normalizeDomain`, which would parse it but fail the final dot check in the hostname (after normalisation), again falling back to keyword. OpenSEO treats these as keywords, not domains, since they cannot be resolved as public internet hostnames.

### How does OpenSEO handle internationalised domain names (IDNs)?

The `normalizeDomain` function uses the native `URL` class for parsing, which automatically handles Punycode conversion. An input like "münchen.de" would be normalised to its Punycode form "xn--mnchen-3ya.de" by the URL parser before the dot check and hostname extraction. The `detectTarget` function then returns this canonical ASCII representation with `type: "domain"`.

### Can the detection be overridden if OpenSEO classifies incorrectly?

The source analysis does not reveal explicit override mechanisms in `detectTarget` itself. The function returns a discriminated union with `type` and `value` fields, suggesting downstream consumers could allow manual type correction through UI toggles. However, the core detection logic is strictly heuristic-based with no configuration parameters exposed in [`src/shared/targetDetection.ts`](https://github.com/every-app/open-seo/blob/main/src/shared/targetDetection.ts).

### Why use a dot check instead of a TLD validation list?

OpenSEO avoids maintaining a TLD whitelist to minimise maintenance overhead and support private/internal domains used in enterprise contexts. The dot check catches the vast majority of real-world cases while remaining resilient to new gTLDs, country-code variations, and internal domain suffixes without code changes. Invalid domains that pass the loose check fail during `normalizeDomain`'s `URL` parsing or final hostname validation.