Browse documentation

Specifications

RankFlow AI — Citation Network Manager Documentation

│ Citation Network (30 Sites) │

docs/specs/citation-network.md
On this page

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;
}

Form-Based Submission (Hyperbrowser)#

// 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