Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,16 @@
# No required env vars - afdocs runs without API keys
# Public site URL
NEXT_PUBLIC_SITE_URL=https://agentscore.fern.dev

# RDS Postgres connection for the `scores` table (agent_score DB on fern-prod-enc).
# Replaces the old SUPABASE_URL / SUPABASE_SECRET_KEY PostgREST data path (FER-11415).
AGENT_SCORE_DATABASE_URL=postgres://USER:PASSWORD@fern-prod-enc.cihbconq6tcp.us-east-1.rds.amazonaws.com:5432/agent_score?sslmode=require

# S3 bucket for OG images (replaces Supabase Storage `og-images`).
# Provisioned by the fern-platform agent-score-deploy CDK stack.
OG_S3_BUCKET=agent-score-prod-og-images
OG_S3_REGION=us-east-1
# Optional: override the public URL base (defaults to https://<bucket>.s3.<region>.amazonaws.com)
# OG_S3_PUBLIC_URL_BASE=https://agent-score-prod-og-images.s3.us-east-1.amazonaws.com

# Admin route secret (unchanged)
# ADMIN_SECRET=...
28 changes: 13 additions & 15 deletions app/api/admin/dedupe/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server';
import { createClient } from '@supabase/supabase-js';
import { query } from '@/lib/db';
import { fetchOgName } from '@/lib/og-name';

export const runtime = 'nodejs';
Expand All @@ -16,18 +16,16 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY!
);

// Fetch all rows ordered by scored_at desc (most recent first)
const { data: rows, error } = await supabase
.from('scores')
.select('slug, name, docs_url, scored_at')
.order('scored_at', { ascending: false });

if (error) return NextResponse.json({ error: error.message }, { status: 500 });
let rows: { slug: string; name: string; docs_url: string; scored_at: string }[];
try {
const res = await query<{ slug: string; name: string; docs_url: string; scored_at: string }>(
`SELECT slug, name, docs_url, scored_at FROM public.scores ORDER BY scored_at DESC`
);
rows = res.rows;
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
}

// Dedupe by normalized docs_url — keep most recent (first in desc order)
const seen = new Set<string>();
Expand All @@ -43,8 +41,8 @@ export async function POST(request: Request) {
}

// Delete duplicates
for (const slug of toDelete) {
await supabase.from('scores').delete().eq('slug', slug);
if (toDelete.length) {
await query(`DELETE FROM public.scores WHERE slug = ANY($1)`, [toDelete]);
}

// Re-fetch OG names for auto-generated names (slug-like, no spaces)
Expand All @@ -55,7 +53,7 @@ export async function POST(request: Request) {
if (isAutoGeneratedName(row.name)) {
const ogName = await fetchOgName(row.docs_url);
if (ogName && ogName !== row.name && ogName.length > 1) {
await supabase.from('scores').update({ name: ogName }).eq('slug', row.slug);
await query(`UPDATE public.scores SET name = $1 WHERE slug = $2`, [ogName, row.slug]);
nameUpdates.push({ slug: row.slug, from: row.name, to: ogName });
}
}
Expand Down
32 changes: 15 additions & 17 deletions app/api/admin/scrub-blocked/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server';
import { createClient } from '@supabase/supabase-js';
import { query } from '@/lib/db';
import { isBlockedDomain } from '@/lib/blocked-domains';

export const runtime = 'nodejs';
Expand All @@ -10,16 +10,15 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY!
);

const { data: rows, error } = await supabase
.from('scores')
.select('slug, docs_url');

if (error) return NextResponse.json({ error: error.message }, { status: 500 });
let rows: { slug: string; docs_url: string }[];
try {
const res = await query<{ slug: string; docs_url: string }>(
`SELECT slug, docs_url FROM public.scores`
);
rows = res.rows;
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
}

const toDelete = rows
.filter(r => isBlockedDomain(r.docs_url) || r.slug === 'unknown')
Expand All @@ -29,12 +28,11 @@ export async function POST(request: Request) {
return NextResponse.json({ deleted: 0, slugs: [] });
}

const { error: delError } = await supabase
.from('scores')
.delete()
.in('slug', toDelete);

if (delError) return NextResponse.json({ error: delError.message }, { status: 500 });
try {
await query(`DELETE FROM public.scores WHERE slug = ANY($1)`, [toDelete]);
} catch (delError) {
return NextResponse.json({ error: delError instanceof Error ? delError.message : String(delError) }, { status: 500 });
}

return NextResponse.json({ deleted: toDelete.length, slugs: toDelete });
}
5 changes: 4 additions & 1 deletion app/api/demo/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ export async function POST(req: Request) {
};

let text: string;
if (source === 'check results gate' && url) {
if (source === 'scoring timeout') {
const target = url ? ` \`${url}\`` : '';
text = `:hourglass: rerun${target} locally for \`${email}\` (site timed out in prod)`;
} else if (source === 'check results gate' && url) {
text = `:eyes: \`${email}\` requested to see the results on ${url}`;
} else if (source) {
const emoji = SOURCE_EMOJI[source] ?? ':calendar:';
Expand Down
111 changes: 18 additions & 93 deletions app/api/score/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,12 @@ import { AFDOCS_VERSION } from "@/lib/scoring";
import { inferCategory } from "@/lib/categorize";
import { isBlockedDomain } from "@/lib/blocked-domains";
import { resolveSlugAlias } from "@/lib/slug-aliases";
import { detectDocsUrl } from "@/lib/docs-detection";
import { urlToSlug, nameToSlug } from "@/lib/slug";

export const runtime = "nodejs";
export const maxDuration = 300;

const DOCS_SUBDOMAINS = /^(docs|developer|api|reference|developers|learn)\./i;
const DOCS_PATHS = /\/(docs|api|reference|guides|developer|sdk|learn|manual|documentation)\//i;
const DOCS_PLATFORMS = /(readme\.io|gitbook\.io|mintlify\.app|buildwithfern\.com\/learn|\.fern\.dev|\.readme\.io|\.gitbook\.io|github\.io|notion\.site)/i;

// ---------------------------------------------------------------------------
// Rate limiting — cookie-based + IP-based, 5 scoring requests per hour
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -116,95 +114,6 @@ function writeJob(jobId: string, data: Record<string, unknown>) {
}
}

// ---------------------------------------------------------------------------
// Docs-site detection
// ---------------------------------------------------------------------------

async function detectDocsUrl(url: string): Promise<{ isLikely: boolean; warning?: string; suggestion?: string }> {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return { isLikely: false, warning: "Invalid URL format." };
}

const host = parsed.hostname;
const pathStr = parsed.pathname + "/";

if (DOCS_SUBDOMAINS.test(host)) return { isLikely: true };
if (DOCS_PATHS.test(pathStr)) return { isLikely: true };
if (DOCS_PLATFORMS.test(host + parsed.pathname)) return { isLikely: true };

// An llms.txt is a strong docs signal — but marketing sites increasingly ship one
// too (e.g. monday.com serves /llms.txt from its product homepage). For a bare apex
// root it's not sufficient on its own; defer to the homepage content check below so
// a marketing landing page can still be rejected. For any deeper path it stands.
const isRoot = parsed.pathname === "/" || parsed.pathname === "";
let hasLlms = false;
try {
const r = await fetch(`${parsed.origin}/llms.txt`, {
signal: AbortSignal.timeout(5000),
headers: { "User-Agent": "Mozilla/5.0 (compatible; AgentScore/1.0)" },
});
hasLlms = r.ok;
} catch { /* ignore */ }
if (hasLlms && !isRoot) return { isLikely: true };

try {
const r = await fetch(url, {
signal: AbortSignal.timeout(8000),
headers: { "User-Agent": "Mozilla/5.0 (compatible; AgentScore/1.0)", Accept: "text/html" },
});
if (!r.ok) {
return { isLikely: false, warning: `The URL returned HTTP ${r.status}. Verify it is publicly accessible.` };
}
const html = await r.text();
const title = html.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]?.toLowerCase() ?? "";
if (/docs|documentation|api\s|reference|developer|quickstart/i.test(title)) return { isLikely: true };
if ((html.match(/<pre|<code/g) ?? []).length >= 3) return { isLikely: true };
if (/getting started|api reference|quickstart|sdk reference/i.test(html)) return { isLikely: true };
const baseDomain = host.replace(/^www\./, "");
return {
isLikely: false,
warning: `This URL looks like a marketing or product site, not a documentation site.`,
suggestion: `docs.${baseDomain}, ${parsed.origin}/docs, or ${parsed.origin}/api`,
};
} catch {
// Couldn't analyze the page — if it advertised an llms.txt, trust that rather
// than reject on a fetch failure (only a *visible* marketing page is rejected).
if (hasLlms) return { isLikely: true };
return {
isLikely: false,
warning: `Could not fetch the URL — it may be protected by bot-detection.`,
suggestion: `docs.${parsed.hostname.replace(/^www\./, "")}`,
};
}
}

// ---------------------------------------------------------------------------
// Slug helpers
// ---------------------------------------------------------------------------

function urlToSlug(url: string): string {
try {
const parsed = new URL(url);
const host = parsed.hostname.replace(/^www\./, "");
const pathPart = parsed.pathname.replace(/\//g, "-").replace(/^-+|-+$/g, "");
const base = pathPart ? `${host}-${pathPart}` : host;
return base.replace(/[^a-z0-9-]/gi, "-").replace(/-+/g, "-").toLowerCase().slice(0, 80);
} catch {
return "unknown";
}
}

function nameToSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
}

// ---------------------------------------------------------------------------
// Job runner
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -438,6 +347,22 @@ export async function POST(request: Request) {
const aliasSlug = resolveSlugAlias(rawSlug);
console.log("[score] resolved slug:", rawSlug, "name:", effectiveName, rawSlug !== aliasSlug ? `(alias → ${aliasSlug})` : '');

// An explicit alias means this domain should ALWAYS surface a curated entry and never be
// scored — e.g. the monday.com marketing apex resolves to its developer-docs entry. Resolve
// it up front, independent of the force/dev cache path below and before docs detection, so
// the user is navigated straight to that entry instead of hitting a "not a docs site"
// rejection. Falls through to the normal flow only if the curated entry is missing.
if (aliasSlug !== rawSlug) {
try {
const canonical = await getScoreBySlug(aliasSlug);
if (canonical) {
console.log("[score] alias redirect:", rawSlug, "→", canonical.slug);
return NextResponse.json({ existing: true, slug: canonical.slug });
}
console.log("[score] alias target not found, falling through:", aliasSlug);
} catch { /* lookup failed — fall through to normal flow */ }
}

// Return cached result if company already exists (skip when force=true or in development).
// Prefer the alias target so a typed domain points at the curated entry.
if (!force && process.env.NODE_ENV !== 'development') {
Expand Down
1 change: 0 additions & 1 deletion components/CTASection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ export default function CTASection() {
<div className="cta-built-trusted-label">Trusted by</div>
<div className="cta-built-logos">
<img src="/agent-score/nvidia-top.svg" alt="NVIDIA" className="cta-logo" />
<img src="/agent-score/openrouter-top.svg" alt="OpenRouter" className="cta-logo" style={{ height: '16px' }} />
<img src="/agent-score/elevenlabs-top.svg" alt="ElevenLabs" className="cta-logo" />
<img src="/agent-score/twilio-top.svg" alt="Twilio" className="cta-logo" />
<img src="/agent-score/adobe-top.svg" alt="Adobe" className="cta-logo" />
Expand Down
15 changes: 8 additions & 7 deletions components/DemoModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { useState, useEffect, useRef } from 'react';

const DEMO_SUBMITTED_KEY = 'demo_submitted';

export default function DemoModal({ onClose, source }: { onClose: () => void; source?: string }) {
export default function DemoModal({ onClose, source, url }: { onClose: () => void; source?: string; url?: string }) {
const isTimeout = source === 'scoring timeout';
const [email, setEmail] = useState('');
const [state, setState] = useState<'idle' | 'loading' | 'success' | 'error' | 'duplicate'>('idle');
const inputRef = useRef<HTMLInputElement>(null);
Expand All @@ -28,7 +29,7 @@ export default function DemoModal({ onClose, source }: { onClose: () => void; so
const res = await fetch('/agent-score/api/demo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim(), source }),
body: JSON.stringify({ email: email.trim(), source, url }),
});
if (!res.ok) throw new Error();
sessionStorage.setItem(DEMO_SUBMITTED_KEY, '1');
Expand Down Expand Up @@ -99,15 +100,15 @@ export default function DemoModal({ onClose, source }: { onClose: () => void; so
<path d="M74.3506 187.414H92.248V196.409H74.3506V187.414Z" fill="#51C233"/>
<path d="M401.863 405.72H419.765V414.714H401.863V405.72Z" fill="#51C233"/>
</svg>
<p className="demo-success-text">We will get back to you to book a demo.</p>
<p className="demo-success-text">{isTimeout ? "We'll run your score locally and email you when it's ready." : 'We will get back to you to book a demo.'}</p>
</div>
) : (
<>
<svg width="24" height="24" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ color: '#00e87b' }}>
<path d="M205.949 125.941C193.314 115.26 174.279 110.979 157.408 123.448C156.632 124.012 155.667 123.048 156.255 122.295C160.255 117.143 164.891 111.59 168.632 106.014C172.443 100.298 178.137 96.2039 184.702 94.2041C219.643 83.6172 209.149 32 209.149 32C209.149 32 155.173 35.4819 161.832 82.0409C162.938 89.8282 160.867 97.7567 155.997 103.944C150.02 111.496 143.079 118.719 138.044 123.942C136.985 125.024 135.197 123.989 135.62 122.53C140.491 106.132 144.044 80.7705 127.174 64.4196L103.433 44.7043L98.8681 50.7271C85.2918 68.6308 89.2682 93.8748 107.197 107.426C117.48 115.19 122.138 123.636 121.409 132.905C120.962 138.458 118.444 143.657 114.68 147.774C107.597 155.538 100.986 163.866 95.8799 173.512C95.174 174.853 93.127 174.336 93.1976 172.806C93.927 156.879 92.3976 120.977 65.5745 108.155L35.5515 96.5568L33.2221 103.497C25.6693 125.894 38.022 149.821 60.3981 157.42C79.8566 164.031 86.7977 176.571 82.1154 195.368C81.9037 196.05 78.5155 215.413 78.9861 224H100.562C101.292 210.684 115.268 201.932 127.385 207.367C130.797 208.896 134.303 211.084 137.903 213.907C157.197 229.105 185.62 225.506 200.796 206.19L205.125 200.685L177.832 181.088C159.102 166.36 134.115 173.018 115.621 185.628C114.068 186.687 112.091 184.993 112.962 183.299C135.315 139.446 164.373 139.54 175.761 149.28C189.572 161.09 210.49 158.973 222.207 145.116L225.572 141.14L205.925 125.941H205.949Z" fill="currentColor"/>
</svg>
<p className="demo-label">Book a demo with Fern</p>
<p className="demo-subtitle">Enter your email and we&apos;ll set you up with an agent-friendly docs site.</p>
<p className="demo-label">{isTimeout ? 'Get my score' : 'Book a demo with Fern'}</p>
<p className="demo-subtitle">{isTimeout ? "Your docs site is too large to score in the browser, so we need to run it locally. Drop your email and we'll send you the result." : "Enter your email and we'll set you up with an agent-friendly docs site."}</p>
<form onSubmit={handleSubmit} className="demo-form">
<input
ref={inputRef}
Expand All @@ -119,14 +120,14 @@ export default function DemoModal({ onClose, source }: { onClose: () => void; so
required
/>
<button type="submit" className="demo-submit" disabled={state === 'loading' || state === 'duplicate'}>
{state === 'loading' ? 'Sending...' : 'Request demo'}
{state === 'loading' ? 'Sending...' : isTimeout ? 'Get my score' : 'Request demo'}
</button>
</form>
{state === 'error' && (
<p className="demo-error">Something went wrong. Please try again.</p>
)}
{state === 'duplicate' && (
<p className="demo-error">We&apos;re excited to chat with you too! You can only request to book a demo once.</p>
<p className="demo-error">{isTimeout ? "You've already requested a score. We'll be in touch once it's ready." : "We're excited to chat with you too! You can only request to book a demo once."}</p>
)}
</>
)}
Expand Down
6 changes: 3 additions & 3 deletions components/ScoreChecker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export default function ScoreChecker() {
const [stepFrame, setStepFrame] = useState(0);
const [error, setError] = useState('');
const [isTimeout, setIsTimeout] = useState(false);
const [demoOpen, setDemoOpen] = useState(false);
const [demoOpen, setDemoOpen] = useState(process.env.NODE_ENV === 'development');
const [notifyOpen, setNotifyOpen] = useState(false);
const [result, setResult] = useState<{ score: number; grade: string } | null>(null);
const [elapsed, setElapsed] = useState(0);
Expand Down Expand Up @@ -207,7 +207,7 @@ export default function ScoreChecker() {
{isTimeout ? (
<>
Wow! That&apos;s a really big site.{' '}
<button className="hsf-timeout-link" onClick={() => setDemoOpen(true)}>Give us your email</button>
<button type="button" className="hsf-timeout-link" onClick={() => setDemoOpen(true)}>Give us your email</button>
{' '}and we&apos;ll run the score locally and get back to you.
</>
) : (
Expand All @@ -222,7 +222,7 @@ export default function ScoreChecker() {
</form>
</div>
</div>
{demoOpen && <DemoModal onClose={() => setDemoOpen(false)} source="scoring timeout" />}
{demoOpen && <DemoModal onClose={() => setDemoOpen(false)} source="scoring timeout" url={url || scoringUrlRef.current} />}
{notifyOpen && <NotifyModal url={url} onClose={() => setNotifyOpen(false)} />}
</>
);
Expand Down
Loading