Version: 1.0.0
Service Path: src/server/services/citation/
Network Size: 30 sites (25 directories + 5 owned blogs)
Primary Moat: Owned citation network + NAP consistency
1. Network Architecture#
┌─────────────────────────────────────────────────────────────┐
│ Citation Network (30 Sites) │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ India-Specific │ │ Global/General │ │
│ │ 10 Directories │ │ 10 Directories │ │
│ │ │ │ │ │
│ │ • Justdial │ │ • Yelp │ │
│ │ • Practo │ │ • Foursquare │ │
│ │ • Sulekha │ │ • Hotfrog │ │
│ │ • Lybrate │ │ • Brownbook │ │
│ │ • 1mg │ │ • Cylex │ │
│ │ • IndiaMART │ │ • ... │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Owned Blog │ │ Global Maps │ │
│ │ Sites (5) │ │ (5) │ │
│ │ │ │ │ │
│ │ • kerala-health │ │ • Bing Places │ │
│ │ • medical-guide │ │ • Apple Maps │ │
│ │ • ... │ │ • TomTom │ │
│ │ │ │ • HERE │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Network Composition#
| Type |
Count |
Priority |
Examples |
| India-specific directories |
10 |
Phase 1 |
Justdial, Practo, Sulekha, Lybrate, 1mg, IndiaMART |
| General directories |
10 |
Phase 1 |
Yelp, Foursquare, Hotfrog, Brownbook, Cylex |
| Owned blog sites |
5 |
Phase 1 (create), Phase 2 (authority) |
kerala-health.rankflow.in, medical-guide.rankflow.in |
| Global maps/directories |
5 |
Phase 2 |
Bing Places, Apple Maps, TomTom, HERE |
2. Citation Directory Registry#
Database Schema#
model CitationDirectory {
id String @id @default(cuid())
name String @unique // machine name: "justdial"
displayName String // "Justdial"
domain String // "justdial.com"
category String // "medical", "general", "local"
authorityScore Int? // Domain authority 0-100
isActive Boolean @default(true)
requiresCaptcha Boolean @default(false)
requiresPhoneVerify Boolean @default(false)
submissionType String @default("FORM") // API, FORM, EMAIL
signupFlow Json // JSON schema of signup flow
submissionFields Json // Required fields
rateLimit Json @default("{}")
successPatterns String[] @default([])
failurePatterns String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("citation_directories")
}
Directory Submission Types#
| Type |
Mechanism |
Examples |
Complexity |
API |
REST API call |
Practo (if available), IndiaMART |
Low |
FORM |
Browser automation (Puppeteer/Playwright) |
Justdial, Sulekha, Yelp |
High |
EMAIL |
Email-based submission |
Some niche directories |
Medium |
Submission Fields per Directory#
{
"justdial": {
"fields": [
{ "name": "business_name", "required": true, "maxLength": 100 },
{ "name": "address", "required": true, "maxLength": 500 },
{ "name": "city", "required": true },
{ "name": "phone", "required": true, "pattern": "^\\+91[0-9]{10}$" },
{ "name": "category", "required": true, "options": ["Doctors", "Dentists", "Clinics"] },
{ "name": "description", "required": true, "maxLength": 500 },
{ "name": "website", "required": false },
{ "name": "hours", "required": false }
],
"captcha": true,
"phoneVerify": true
}
}
3. Submission Engine#
Plugin Architecture#
// src/server/services/citation/submission-engine.ts
interface CitationSubmitter {
name: string;
directory: CitationDirectory;
// Check if business already listed
async checkExists(nap: NAPData): Promise<boolean>;
// Submit new listing
async submit(data: SubmissionData): Promise<SubmissionResult>;
// Update existing listing
async update(url: string, data: SubmissionData): Promise<SubmissionResult>;
// Delete listing
async delete(url: string): Promise<boolean>;
}
interface SubmissionData {
practice: Practice;
location: Location;
description: string;
credentials?: { username: string; password: string };
}
interface SubmissionResult {
success: boolean;
url?: string;
screenshotUrl?: string;
error?: string;
requiresManual?: boolean;
}
// src/server/services/citation/submitters/form-submitter.ts
import { HyperbrowserClient } from "@/server/services/browser/hyperbrowser";
export class FormSubmitter implements CitationSubmitter {
private browser: HyperbrowserClient;
constructor(public directory: CitationDirectory) {
this.browser = new HyperbrowserClient();
}
async submit(data: SubmissionData): Promise<SubmissionResult> {
const session = await this.browser.createSession();
try {
// 1. Navigate to signup page
const signupUrl = this.directory.signupFlow.steps[0].url;
await this.browser.navigate(session.id, signupUrl);
// 2. Fill form fields
for (const field of this.directory.submissionFields) {
const value = this.getFieldValue(field, data);
const selector = field.selector;
await this.browser.fillForm(session.id, selector, value);
}
// 3. Handle CAPTCHA if present
if (this.directory.requiresCaptcha) {
await this.browser.solveCaptcha(session.id);
}
// 4. Submit form
await this.browser.click(session.id, "button[type='submit']");
// 5. Wait for confirmation
await this.browser.waitForSelector(session.id, ".success-message", 10000);
// 6. Capture screenshot
const screenshot = await this.browser.screenshot(session.id);
const screenshotUrl = await uploadToS3(
screenshot,
`citations/${data.practice.id}/${this.directory.name}.png`
);
// 7. Extract listing URL
const pageUrl = await this.browser.getUrl(session.id);
return {
success: true,
url: pageUrl,
screenshotUrl,
};
} catch (error) {
return {
success: false,
error: (error as Error).message,
requiresManual: true,
};
} finally {
await this.browser.closeSession(session.id);
}
}
private getFieldValue(field: any, data: SubmissionData): string {
const map: Record<string, string> = {
business_name: data.location.businessName,
address: data.location.address,
city: data.location.city,
phone: data.location.phone,
email: data.location.email || "",
website: data.location.website || `https://${data.practice.subdomain}.rankflow.ai`,
description: data.description,
category: data.location.category,
};
return map[field.name] || "";
}
}
API-Based Submission#
// src/server/services/citation/submitters/api-submitter.ts
export class ApiSubmitter implements CitationSubmitter {
constructor(public directory: CitationDirectory) {}
async submit(data: SubmissionData): Promise<SubmissionResult> {
const endpoint = this.directory.apiEndpoint;
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${data.credentials?.password}`,
},
body: JSON.stringify({
name: data.location.businessName,
address: data.location.address,
city: data.location.city,
phone: data.location.phone,
category: data.location.category,
description: data.description,
website: data.location.website,
}),
});
if (!response.ok) {
return {
success: false,
error: await response.text(),
};
}
const result = await response.json();
return {
success: true,
url: result.listing_url,
};
}
}
4. NAP Monitoring#
NAP Consistency Check#
// src/server/services/citation/nap-monitor.ts
import { FirecrawlClient } from "@/server/services/browser/firecrawl";
export async function verifyNapConsistency(citation: Citation): Promise<NAPResult> {
const firecrawl = new FirecrawlClient();
const result = await firecrawl.scrape(citation.directoryUrl!, {
schema: {
type: "object",
properties: {
business_name: { type: "string" },
address: { type: "string" },
phone: { type: "string" },
},
},
});
const extracted = result.data?.extract || {};
const expected = citation.napSnapshot as any;
return {
nameMatch: fuzzyMatch(expected.name, extracted.business_name || ""),
addressMatch: fuzzyMatch(expected.address, extracted.address || ""),
phoneMatch: normalizePhone(expected.phone) === normalizePhone(extracted.phone || ""),
extracted,
};
}
function fuzzyMatch(expected: string, found: string): boolean {
const normExpected = expected.toLowerCase().replace(/[^a-z0-9]/g, "");
const normFound = found.toLowerCase().replace(/[^a-z0-9]/g, "");
return normFound.includes(normExpected) || normExpected.includes(normFound);
}
function normalizePhone(phone: string): string {
return phone.replace(/\D/g, "");
}
NAP Match Status#
| Status |
Meaning |
Action |
MATCHED |
All NAP fields match |
None |
MISMATCH_NAME |
Business name differs |
Alert, schedule update |
MISMATCH_ADDRESS |
Address differs |
Alert, schedule update |
MISMATCH_PHONE |
Phone differs |
Alert, schedule update |
MISMATCH_ALL |
Multiple fields wrong |
Urgent alert, manual review |
NOT_FOUND |
Listing no longer exists |
Alert, schedule resubmission |
Monthly NAP Check Job#
// src/server/bullmq/processors/nap-check.ts
export async function napCheckProcessor(job: Job) {
const { practiceId } = job.data;
const citations = await db.citation.findMany({
where: { practiceId, status: { in: ["SUBMITTED", "VERIFIED"] } },
});
const results = await Promise.all(
citations.map(async (citation) => {
const result = await verifyNapConsistency(citation);
let matchStatus: NAPMatchStatus = "MATCHED";
if (!result.nameMatch) matchStatus = "MISMATCH_NAME";
if (!result.addressMatch) matchStatus = "MISMATCH_ADDRESS";
if (!result.phoneMatch) matchStatus = "MISMATCH_PHONE";
if (!result.nameMatch && !result.addressMatch && !result.phoneMatch) {
matchStatus = "MISMATCH_ALL";
}
if (!result.extracted.business_name) matchStatus = "NOT_FOUND";
await db.citation.update({
where: { id: citation.id },
data: {
matchStatus,
lastScannedAt: new Date(),
scanResult: result.extracted,
},
});
return { citationId: citation.id, matchStatus };
})
);
// Alert if any mismatches
const mismatches = results.filter(r => r.matchStatus !== "MATCHED");
if (mismatches.length > 0) {
await email.send({
to: "admin@rankflow.ai",
subject: `NAP Mismatches: ${mismatches.length} citations for practice ${practiceId}`,
body: mismatches.map(m => `- ${m.citationId}: ${m.matchStatus}`).join("\n"),
});
}
return { checked: citations.length, mismatches: mismatches.length };
}
5. Owned Blog Sites#
Site Architecture#
kerala-health.rankflow.in
├── / (homepage) — Editorial content about Kerala health
├── /doctors/ — Directory of doctors by specialty
├── /dr-smith-dental/ — Client citation page
│ ├── NAP data
├── Unique description
├── Backlink to client site
└── Schema markup
├── /articles/ — Health articles (AI-generated)
└── sitemap.xml
Database Schema#
model OwnedBlogSite {
id String @id @default(cuid())
practiceId String? // If linked to a specific practice
name String
domain String @unique
subdomain String? // If hosted as subdomain of rankflow
niche String // "kerala-health", "medical-guide", etc.
authorityScore Int? @default(0)
postCount Int @default(0)
isActive Boolean @default(true)
lastPublishedAt DateTime?
settings Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
practice Practice? @relation(fields: [practiceId], references: [id], onDelete: SetNull)
posts BlogSitePost[]
@@map("owned_blog_sites")
}
model BlogSitePost {
id String @id @default(cuid())
blogSiteId String
contentPieceId String?
title String
slug String
content String @db.Text
excerpt String?
status String @default("DRAFT") // DRAFT, PUBLISHED, FAILED
publishedUrl String?
seoTitle String?
seoDescription String?
focusKeywords String[] @default([])
publishedAt DateTime?
failedReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
blogSite OwnedBlogSite @relation(fields: [blogSiteId], references: [id], onDelete: Cascade)
@@unique([blogSiteId, slug])
@@map("blog_site_posts")
}
Content Strategy for Owned Sites#
| Content Type |
Frequency |
Purpose |
| Health articles |
2-3/week |
Build topical authority |
| Doctor profiles |
Per client |
Citation + backlink |
| City guides |
1/week |
Local relevance |
| FAQ pages |
1/week |
Long-tail SEO |
6. Citation Publishing Flow#
End-to-End Flow#
1. Client onboarded → NAP data validated
2. AI generates 30 unique descriptions (one per site)
3. Job queue pushes to each site:
a. API-based: Direct API call
b. Form-based: Puppeteer/Playwright automation
c. Owned blog: Direct DB insert + SSG re-render
4. NAP consistency verified per site (automated check)
5. URLs stored in citation table for monitoring
6. Screenshot captured for proof of submission
Inngest Workflow#
// src/server/inngest/functions/citation-build.ts
export const citationBuilder = inngest.createFunction(
{
id: "citation-builder",
retries: 3,
concurrency: { limit: 3 },
},
{ event: "skill/13-citation-submit" },
async ({ event, step }) => {
const { practice_id, location_id } = event.data;
// Step 1: Get data
const [practice, location] = await step.run("get-data", async () => {
return await Promise.all([
db.practice.findUnique({ where: { id: practice_id } }),
db.location.findUnique({ where: { id: location_id } }),
]);
});
// Step 2: Get active directories
const directories = await step.run("get-directories", async () => {
return await db.citationDirectory.findMany({ where: { isActive: true } });
});
// Step 3: Generate descriptions
const descriptions = await step.run("generate-descriptions", async () => {
return await Promise.all(
directories.map(async (dir) => ({
directoryId: dir.id,
description: await ai.generate({
task: "citation_description",
context: { practice, location, directory: dir },
}),
}))
);
});
// Step 4: Submit to each directory
const results = await Promise.all(
directories.map(async (dir, i) => {
return await step.run(`submit-${dir.name}`, async () => {
try {
const submitter = createSubmitter(dir);
return await submitter.submit({
practice,
location,
description: descriptions[i].description,
});
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
})
);
// Step 5: Store results
await step.run("store-results", async () => {
await db.citation.createMany({
data: results.map((r, i) => ({
practiceId: practice_id,
locationId: location_id,
directoryName: directories[i].name,
status: r.success ? "SUBMITTED" : "FAILED",
directoryUrl: r.url,
submittedAt: r.success ? new Date() : null,
errorMessage: r.error || null,
screenshotUrl: r.screenshotUrl,
napSnapshot: {
name: location.businessName,
address: location.address,
phone: location.phone,
},
})),
});
});
// Step 6: Schedule verification (1 week later)
await step.sleep("7d");
await step.run("schedule-verification", async () => {
await inngest.send({
name: "skill/14-citation-verify-nap",
data: { practice_id, location_id },
});
});
return {
submitted: results.filter(r => r.success).length,
failed: results.filter(r => !r.success).length
};
}
);
7. API Reference#
tRPC Router#
// src/server/api/routers/citation.ts
export const citationRouter = createTRPCRouter({
listDirectories: protectedProcedure.query(async () => {
return db.citationDirectory.findMany({ where: { isActive: true } });
}),
list: practiceProcedure
.input(z.object({ locationId: z.string() }))
.query(async ({ ctx, input }) => {
return db.citation.findMany({
where: { practiceId: ctx.practice.id, locationId: input.locationId },
include: { directory: true },
});
}),
submit: practiceProcedure
.input(z.object({
locationId: z.string(),
directoryNames: z.array(z.string()).min(1).max(50),
}))
.mutation(async ({ ctx, input }) => {
// Trigger Inngest workflow
const result = await inngest.send({
name: "skill/13-citation-submit",
data: {
practice_id: ctx.practice.id,
location_id: input.locationId,
},
});
return { jobId: result.ids[0], status: "QUEUED" };
}),
verifyNap: practiceProcedure
.input(z.object({ citationId: z.string() }))
.mutation(async ({ input }) => {
const citation = await db.citation.findUnique({
where: { id: input.citationId },
});
if (!citation) throw new TRPCError({ code: "NOT_FOUND" });
const result = await verifyNapConsistency(citation);
await db.citation.update({
where: { id: input.citationId },
data: {
matchStatus: result.nameMatch && result.addressMatch && result.phoneMatch
? "MATCHED"
: "MISMATCH_NAME",
lastScannedAt: new Date(),
scanResult: result.extracted,
},
});
return result;
}),
delete: practiceProcedure
.input(z.object({ citationId: z.string() }))
.mutation(async ({ input }) => {
const citation = await db.citation.findUnique({
where: { id: input.citationId },
include: { directory: true },
});
if (!citation) throw new TRPCError({ code: "NOT_FOUND" });
// Attempt to delete from directory
if (citation.directoryUrl) {
const submitter = createSubmitter(citation.directory);
await submitter.delete(citation.directoryUrl);
}
await db.citation.update({
where: { id: input.citationId },
data: { status: "REMOVED", directoryUrl: null },
});
return { success: true };
}),
});
8. Success Metrics#
Target Metrics#
| Metric |
Target |
Measurement |
| Citation success rate |
> 80% |
Submitted / Attempted |
| NAP consistency |
> 95% |
Matched / Total live |
| Owned site DA |
> 40 |
Moz/Ahrefs |
| Time to 30 citations |
< 7 days |
From onboarding |
| Citation uptime |
> 95% |
Live / Total submitted |
Per-Directory Tracking#
| Directory |
Success Rate |
Avg Time |
CAPTCHA Rate |
Notes |
| Justdial |
70% |
5 min |
High |
Phone verify required |
| Practo |
85% |
2 min |
Low |
API preferred |
| Sulekha |
75% |
4 min |
Medium |
Form-based |
| Yelp |
90% |
3 min |
Low |
Clean API |
| Owned blogs |
99% |
1 min |
None |
Direct DB insert |
End of Citation Network Manager Documentation