Aivis Perplexity Rules — Free Perplexity Rules Template
    Neura MarketNeura Market/Perplexity
    ChatGPTChatGPTClaudeClaudeGeminiGeminiCursorCursorGrokGrokPerplexityPerplexityDeepSeekDeepSeek
    CoPilotCoPilotStable DiffusionStable DiffusionMidjourneyMidjourney
    View All Directories
    OverviewRulesPromptsMCPsAgentsGamesBlogVideosGuidesCoursesCommunityTrending
    PerplexityRulesAivis Perplexity Rules
    Back to Rules

    Aivis Perplexity Rules

    intruvurt July 19, 2026
    0 copies 0 downloads
    Rule Content
    /**
     * Citation Ranking Engine
     *
     * Enterprise-grade niche competitive ranking detection:
     * - Asks multiple AI models to generate a Top 50/100 list for a given niche
     * - Detects where the target brand ranks in each list
     * - Tracks which model produced the ranking (primary vs fallback)
     * - Surfaces short-form model labels for display in the UI analysis report
     */
    
    import { callAIProvider } from './aiProviders.js';
    import { randomUUID } from 'crypto';
    import { getPool } from './postgresql.js';
    import type { NicheRankingEntry, NicheRankingResult, ModelShortName, ModelRole } from '@aivis/shared-types';
    
    const OPENROUTER_ENDPOINT = 'https://openrouter.ai/api/v1/chat/completions';
    
    const MIN_LIST_ENTRIES = 5;
    const MAX_LIST_ENTRIES = 200;
    const MAX_BRAND_LENGTH = 120;
    const TOP_LIST_LIMIT = 50;
    
    const NICHE_RANKING_SELECT_COLUMNS = `id, target_url, brand_name, niche, niche_keywords, target_rank, in_top_50, in_top_100, top_50, top_100, ranking_model_id, ranking_model_short, ranking_model_role, citation_models_used, ran_at, scheduled_job_id`;
    
    // ─── Model short-name registry ──────────────────────────────────────────────
    
    const MODEL_SHORT_NAME_MAP: Record<string, ModelShortName> = {
      // Current preferred models (synced with OpenRouter model list 2026-05-29)
      // OpenAI
      'openai/gpt-5': 'GPT-5',
      'openai/gpt-5-mini': 'GPT-5 Mini',
      'openai/gpt-5-nano': 'GPT-5 Nano',
      'openai/gpt-5.1': 'GPT-5.1',
      'openai/gpt-5.2': 'GPT-5.2',
      'openai/gpt-5.2-pro': 'GPT-5.2 Pro',
      'openai/gpt-5.3-chat': 'GPT-5.3 Chat',
      'openai/gpt-5.4-mini': 'GPT-5.4 Mini',
      'openai/gpt-5.4-nano': 'GPT-5.4 Nano',
      'openai/gpt-5.4-pro': 'GPT-5.4 Pro',
      'openai/gpt-5.5': 'GPT-5.5',
      'openai/gpt-oss-120b': 'GPT-OSS 120B',
      'openai/gpt-4.1': 'GPT-4.1',
      'openai/gpt-4.1-mini': 'GPT-4.1 Mini',
      // Anthropic
      'anthropic/claude-haiku-4.5': 'Claude Haiku 4.5',
      'anthropic/claude-sonnet-4.5': 'Claude Sonnet 4.5',
      'anthropic/claude-sonnet-4.6': 'Claude Sonnet 4.6',
      'anthropic/claude-opus-4.8': 'Claude Opus 4.8',
      'anthropic/claude-sonnet-4': 'Claude Sonnet 4',
      'anthropic/claude-3.5-haiku': 'Claude 3.5 Haiku',
      'anthropic/claude-3.5-sonnet': 'Claude 3.5 Sonnet',
      'anthropic/claude-3-haiku': 'Claude 3 Haiku',
      // xAI
      'x-ai/grok-4.3': 'Grok 4.3',
      'x-ai/grok-4.1-fast': 'Grok 4.1 Fast',  // deprecated, kept for cache hits
      'x-ai/grok-3': 'Grok 3',
      'x-ai/grok-3-mini': 'Grok 3 Mini',
      // Google
      'google/gemini-3.1-pro-preview': 'Gemini 3.1 Pro',
      'google/gemini-3.1-flash-lite-preview': 'Gemini 3.1 Flash Lite',
      'google/gemini-3-flash-preview': 'Gemini 3 Flash',
      'google/gemini-2.5-flash': 'Gemini 2.5 Flash',
      'google/gemini-2.5-flash:free': 'Gemini 2.5 Flash',
      'google/gemini-2.5-flash-lite': 'Gemini 2.5 Flash Lite',
      'google/gemma-4-31b-it': 'Gemma 4 31B',
      'google/gemma-3-27b-it': 'Gemma 3 27B',
      'google/gemma-3-27b-it:free': 'Gemma 3 27B',
      // DeepSeek
      'deepseek/deepseek-v4-pro': 'DeepSeek V4 Pro',
      'deepseek/deepseek-v4-flash': 'DeepSeek V4 Flash',
      'deepseek/deepseek-v3.2': 'DeepSeek V3.2',
      'deepseek/deepseek-r1': 'DeepSeek R1',
      'deepseek/deepseek-r1:free': 'DeepSeek R1',
      'deepseek/deepseek-chat-v3-0324': 'DeepSeek V3',
      'deepseek/deepseek-chat-v3-0324:free': 'DeepSeek V3',
      // Mistral
      'mistralai/mistral-large': 'Mistral Large',
      'mistralai/mistral-medium-3.1': 'Mistral Medium 3.1',
      'mistralai/mistral-small-3.2-24b-instruct-2506': 'Mistral Small 3.2',
      'mistralai/mistral-small-3.2-24b-instruct': 'Mistral Small 3.2',
      'mistralai/codestral-2508': 'Codestral 2508',
      'mistralai/mistral-small-latest': 'Mistral Small',
      'mistralai/mistral-small-3.1-24b-instruct:free': 'Mistral Small 24B',
      // Meta
      'meta-llama/llama-3.3-70b-instruct': 'Llama 3.3 70B',
      'meta-llama/llama-3.3-70b-instruct:free': 'Llama 3.3 70B',
      'meta-llama/llama-4-scout:free': 'Llama 4 Scout',
      // Qwen
      'qwen/qwen3-235b-a22b': 'Qwen3 235B',
      'qwen/qwen3.6-plus': 'Qwen3.6 Plus',
      'qwen/qwen3-32b': 'Qwen3 32B',
      'qwen/qwen3-32b:free': 'Qwen3 32B',
      // Perplexity
      'perplexity/sonar': 'Sonar',
      'perplexity/sonar-pro': 'Sonar Pro',
      // Other
      'inception/mercury-2': 'Mercury 2',
      'minimax/minimax-m2.5': 'MiniMax M2.5',
      'moonshotai/kimi-k2-thinking': 'Kimi K2',
      'z-ai/glm-5': 'GLM-5',
      'z-ai/glm-5.2': 'GLM-5.2',
      'cohere/north-mini-code': 'North Mini Code',
      'cohere/north-mini-code:free': 'North Mini Code',
      'perplexity/sonar-reasoning': 'Sonar Reasoning',
      // Legacy IDs (still valid on OpenRouter, kept for cache hits)
      'openai/gpt-4o': 'GPT-4o',
      'openai/gpt-4o-mini': 'GPT-4o Mini',
      'google/gemini-2.0-flash-exp': 'Gemini 2.0 Flash',
      'google/gemini-2.0-flash-001': 'Gemini 2.0 Flash',
    };
    
    export function modelShortName(modelId: string): ModelShortName {
      return MODEL_SHORT_NAME_MAP[modelId] ?? modelId.split('/').pop()?.split(':')[0] ?? modelId;
    }
    
    // ─── Ranking model candidates (ordered: primary first, then fallbacks) ──────
    
    // Synced with PROVIDERS chain in aiProviders.ts (2026-04-10)
    const RANKING_MODEL_CHAIN: Array<{ model: string; role: ModelRole }> = [
      { model: 'openai/gpt-5-nano', role: 'primary' },
      { model: 'anthropic/claude-sonnet-4.5', role: 'fallback' },
      { model: 'deepseek/deepseek-v3.2', role: 'fallback' },
      { model: 'google/gemini-2.5-flash', role: 'fallback' },
      { model: 'x-ai/grok-4.3', role: 'fallback' },
      { model: 'meta-llama/llama-3.3-70b-instruct', role: 'fallback' },
      { model: 'mistralai/mistral-small-3.2-24b-instruct', role: 'fallback' },
    ];
    
    // Citation verification model chain
    // Synced with PROVIDERS chain in aiProviders.ts (2026-04-10)
    const CITATION_VERIFY_CHAIN: Array<{ model: string; role: ModelRole; platform: string }> = [
      { model: 'openai/gpt-5-nano', role: 'primary', platform: 'chatgpt' },
      { model: 'google/gemini-2.5-flash', role: 'primary', platform: 'google_ai' },
      { model: 'x-ai/grok-4', role: 'primary', platform: 'xai' },
      { model: 'z-ai/glm-5.2', role: 'primary', platform: 'glm' },
      { model: 'anthropic/claude-sonnet-4.5', role: 'primary', platform: 'claude' },
      { model: 'mistralai/mistral-small-3.2-24b-instruct', role: 'fallback', platform: 'claude' },
      { model: 'deepseek/deepseek-v3.2', role: 'primary', platform: 'perplexity' },
    ];
    
    // ─── Prompt builders ─────────────────────────────────────────────────────────
    
    function buildTop50Prompt(niche: string, keywords: string[]): string {
      const nicheContext = keywords.length > 0
        ? `Niche context: ${keywords.slice(0, 8).join(', ')}.`
        : '';
    
      return `You are a senior market analyst. Generate a ranked list of the top ${TOP_LIST_LIMIT} most real, notable, and authoritative products, services, SaaS tools, or businesses in the niche "${niche}". ${nicheContext}
    
    RULES:
    - Rank from #1 to #${TOP_LIST_LIMIT}
    - Use only the real brand or product name - no descriptions
    - Format: one per line as "RANK. BRAND_NAME"
    - Include real SaaS, companies, tools, and platforms only - no placeholders
    - If the niche has fewer than ${TOP_LIST_LIMIT} notable entities, list all you can find
    - Do not include commentary before or after the list
    
    Begin the list now:`;
    }
    
    function buildRankCheckPrompt(brandName: string, niche: string, top50List: string): string {
      return `Below is a ranked list of the top ${TOP_LIST_LIMIT} brands in the "${niche}" niche.
    
    LIST:
    ${top50List.slice(0, 4000)}
    
    TASK: Is "${brandName}" present in this list? If yes, at what rank?
    Reply with ONLY this JSON (no explanation, no markdown):
    {"found": true/false, "rank": NUMBER_OR_NULL, "matched_as": "EXACT_NAME_OR_NULL"}`;
    }
    
    function buildCitationVerifyPrompt(brandName: string, niche: string, keywords: string[]): string {
      const nicheContext = keywords.length > 0
        ? `Niche context: ${keywords.slice(0, 8).join(', ')}.`
        : '';
    
      return `Answer this user question as an answer engine would:
    
    "What are the best ${niche} tools? Does ${brandName} appear among the top recommendations?"
    
    ${nicheContext}
    
    Rules:
    - Give a direct answer first.
    - If ${brandName} is not a strong recommendation, say so plainly.
    - Do not force-include ${brandName}.
    - End with a section exactly named "Sources considered:" and list domain names only.
    - No markdown tables.`;
    }
    
    // ─── Parsing / matching helpers ─────────────────────────────────────────────
    
    function escapeRegex(input: string): string {
      return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    }
    
    function normalizeLoose(value: string): string {
      return value.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
    }
    
    function normalizeTight(value: string): string {
      return value.toLowerCase().replace(/[^a-z0-9]/g, '');
    }
    
    function tokenizeBrand(value: string): string[] {
      return normalizeLoose(value).split(/\s+/).filter(Boolean);
    }
    
    function isGenericToken(token: string): boolean {
      return token.length < 3 || [
        'ai', 'app', 'hq', 'io', 'co', 'inc', 'labs', 'lab', 'tools', 'tool', 'software',
        'platform', 'systems', 'group', 'agency', 'studio', 'blog', 'search', 'guide',
      ].includes(token);
    }
    
    function buildBrandMentionRegexes(targetBrand: string): RegExp[] {
      const loose = normalizeLoose(targetBrand);
      const tight = normalizeTight(targetBrand);
      const tokens = tokenizeBrand(targetBrand).filter((token) => !isGenericToken(token));
    
      const patterns = new Set<string>();
    
      if (loose) {
        patterns.add(`\\b${escapeRegex(loose).replace(/\s+/g, '\\s+')}\\b`);
        patterns.add(`\\b${escapeRegex(loose).replace(/\s+/g, '[-\\s]*')}\\b`);
      }
    
      if (tight && tight.length >= 4) {
        patterns.add(`\\b${escapeRegex(tight)}\\b`);
      }
    
      if (tokens.length >= 2) {
        patterns.add(`\\b${tokens.map(escapeRegex).join('[-\\s]+')}\\b`);
      }
    
      return Array.from(patterns).map((pattern) => new RegExp(pattern, 'i'));
    }
    
    function hasBrandMention(text: string, targetBrand: string): boolean {
      const subject = normalizeLoose(text);
      return buildBrandMentionRegexes(targetBrand).some((regex) => regex.test(subject));
    }
    
    function splitAnswerAndSources(response: string): { answer: string; sources: string } {
      const marker = /\bSources considered:\s*/i;
      const match = marker.exec(response);
      if (!match || match.index < 0) {
        return { answer: response.trim(), sources: '' };
      }
    
      const answer = response.slice(0, match.index).trim();
      const sources = response.slice(match.index + match[0].length).trim();
      return { answer, sources };
    }
    
    function splitSentences(text: string): string[] {
      return text
        .split(/(?<=[.!?])\s+|\n+/)
        .map((s) => s.trim())
        .filter(Boolean);
    }
    
    function safeErrorMessage(err: unknown): string {
      return err instanceof Error ? err.message : String(err);
    }
    
    function safeJsonParse<T>(text: string): T | null {
      const trim = text.trim();
      try {
        return JSON.parse(trim) as T;
      } catch {
        const start = trim.indexOf('{');
        const end = trim.lastIndexOf('}');
        if (start !== -1 && end > start) {
          try {
            return JSON.parse(trim.slice(start, end + 1)) as T;
          } catch {
            return null;
          }
        }
        return null;
      }
    }
    
    function safeParseJsonArray<T>(value: unknown): T[] {
      if (Array.isArray(value)) return value as T[];
      if (typeof value !== 'string') return [];
      try {
        const parsed = JSON.parse(value);
        return Array.isArray(parsed) ? parsed as T[] : [];
      } catch {
        return [];
      }
    }
    
    function parseNumberedList(text: string): Array<{ rank: number; brand: string }> {
      const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
      const entries: Array<{ rank: number; brand: string }> = [];
      const rankLineRe = /^(\d{1,3})[.)\-:]\s*(.+)$/;
    
      for (const line of lines) {
        const m = line.match(rankLineRe);
        if (!m) continue;
        const rank = parseInt(m[1], 10);
        const brand = m[2]
          .replace(/\*\*/g, '')
          .replace(/\s+/g, ' ')
          .replace(/^["'`]+|["'`]+$/g, '')
          .trim();
    
        if (rank >= 1 && rank <= MAX_LIST_ENTRIES && brand.length >= 1 && brand.length <= MAX_BRAND_LENGTH) {
          entries.push({ rank, brand });
        }
      }
    
      const seen = new Set<number>();
      return entries.filter(({ rank }) => {
        if (seen.has(rank)) return false;
        seen.add(rank);
        return true;
      });
    }
    
    function brandMatchesTarget(brand: string, targetBrand: string): boolean {
      const brandLoose = normalizeLoose(brand);
      const targetLoose = normalizeLoose(targetBrand);
      const brandTight = normalizeTight(brand);
      const targetTight = normalizeTight(targetBrand);
    
      if (!brandLoose || !targetLoose || !brandTight || !targetTight) return false;
      if (brandTight === targetTight) return true;
    
      const brandTokens = tokenizeBrand(brand).filter((token) => !isGenericToken(token));
      const targetTokens = tokenizeBrand(targetBrand).filter((token) => !isGenericToken(token));
    
      if (brandTokens.length >= 2 && targetTokens.length >= 2) {
        return brandTokens.join(' ') === targetTokens.join(' ');
      }
    
      if (brandTokens.length === 1 && targetTokens.length === 1) {
        return brandTokens[0] === targetTokens[0] && brandTokens[0].length >= 4;
      }
    
      return false;
    }
    
    function hasNegativeMentionContext(answerText: string, brandName: string): boolean {
      const loose = normalizeLoose(brandName);
      if (!loose) return false;
    
      const patterns = [
        new RegExp(`\\b(?:not|never|does not|did not|would not|is not|isn't|was not|wasn't)\\s+mention(?:ed|s)?\\b.*\\b${escapeRegex(loose).replace(/\s+/g, '\\s+')}\\b`, 'i'),
        new RegExp(`\\b(?:could not|couldn't|cannot|can't|unable to|did not|does not)\\s+(?:find|verify|identify|recommend)\\b.*\\b${escapeRegex(loose).replace(/\s+/g, '\\s+')}\\b`, 'i'),
        new RegExp(`\\b${escapeRegex(loose).replace(/\s+/g, '\\s+')}\\b.*\\b(?:not recommended|not included|not listed|not mentioned|not found)\\b`, 'i'),
      ];
    
      return patterns.some((pattern) => pattern.test(answerText));
    }
    
    function findMentionPosition(answerText: string, brandName: string): number | null {
      const sentences = splitSentences(answerText);
      for (let i = 0; i < sentences.length; i++) {
        if (hasBrandMention(sentences[i], brandName)) {
          return i + 1;
        }
      }
      return null;
    }
    
    function parseSourceDomains(text: string): string[] {
      return text
        .split(/\r?\n|,/)
        .map((line) => line.trim().replace(/^[-*•]\s*/, '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '').toLowerCase())
        .filter(Boolean);
    }
    
    function hasSubstantiveMention(answerText: string, brandName: string): boolean {
      if (!hasBrandMention(answerText, brandName)) return false;
      if (hasNegativeMentionContext(answerText, brandName)) return false;
    
      const sentences = splitSentences(answerText);
      const supportingSentence = sentences.find((sentence) => hasBrandMention(sentence, brandName));
      return Boolean(supportingSentence && supportingSentence.length >= 20);
    }
    
    // ─── Core ranking engine ─────────────────────────────────────────────────────
    
    export interface RankingEngineInput {
      targetUrl: string;
      brandName: string;
      niche: string;
      nicheKeywords: string[];
      apiKey: string;
      userId?: string;
      scheduledJobId?: string;
    }
    
    /**
     * Generate the niche top-50 list using the model chain.
     * Returns the raw list text, which model was used, and its role.
     */
    async function generateNicheList(
      niche: string,
      keywords: string[],
      apiKey: string
    ): Promise<{ text: string; model: string; role: ModelRole } | null> {
      const prompt = buildTop50Prompt(niche, keywords);
    
      for (const candidate of RANKING_MODEL_CHAIN) {
        try {
          const response = await callAIProvider({
            provider: 'openrouter',
            model: candidate.model,
            prompt,
            apiKey,
            endpoint: OPENROUTER_ENDPOINT,
            opts: {
              max_tokens: 2000,
              temperature: 0.2,
              responseFormat: 'text',
              timeoutMs: 25000,
              systemPrompt: 'You are a market analyst producing authoritative ranked competitor lists. Respond only with the numbered list.',
            },
          });
    
          if (!response || response.trim().length < 50) {
            throw new Error('Response too short');
          }
    
          const parsed = parseNumberedList(response);
          if (parsed.length < MIN_LIST_ENTRIES) {
            throw new Error(`Numbered list too short: ${parsed.length} entries`);
          }
    
          return { text: response, model: candidate.model, role: candidate.role };
        } catch (err: unknown) {
          console.warn(`[NicheRanking] ${candidate.model} failed: ${safeErrorMessage(err)}`);
          continue;
        }
      }
    
      return null;
    }
    
    /**
     * Check if the target brand appears in the top list, using the rank-check model chain.
     */
    async function verifyBrandRank(
      brand: string,
      niche: string,
      top50Raw: string,
      apiKey: string
    ): Promise<{ found: boolean; rank: number | null; modelUsed: string; modelRole: ModelRole } | null> {
      const prompt = buildRankCheckPrompt(brand, niche, top50Raw);
    
      for (const candidate of RANKING_MODEL_CHAIN) {
        try {
          const response = await callAIProvider({
            provider: 'openrouter',
            model: candidate.model,
            prompt,
            apiKey,
            endpoint: OPENROUTER_ENDPOINT,
            opts: {
              max_tokens: 120,
              temperature: 0.0,
              responseFormat: 'json_object',
              timeoutMs: 15000,
              systemPrompt: 'You are a strict classifier. Reply only with the requested JSON object.',
            },
          });
    
          const parsed = safeJsonParse<{ found: boolean; rank: number | null; matched_as: string | null }>(response);
          if (parsed !== null && typeof parsed.found === 'boolean') {
            const validRank = typeof parsed.rank === 'number' && parsed.rank >= 1 && parsed.rank <= MAX_LIST_ENTRIES
              ? parsed.rank
              : null;
    
            return {
              found: parsed.found && validRank !== null,
              rank: validRank,
              modelUsed: candidate.model,
              modelRole: candidate.role,
            };
          }
    
          throw new Error('Invalid JSON response');
        } catch (err: unknown) {
          console.warn(`[NicheRanking:verify] ${candidate.model} failed: ${safeErrorMessage(err)}`);
          continue;
        }
      }
    
      return null;
    }
    
    /**
     * Run citation verification across multiple platforms to check whether each platform
     * cites the brand in context of the niche.
     */
    async function runCitationVerification(
      brandName: string,
      niche: string,
      keywords: string[],
      apiKey: string
    ): Promise<NicheRankingResult['citation_models_used']> {
      const prompt = buildCitationVerifyPrompt(brandName, niche, keywords);
      const results: NicheRankingResult['citation_models_used'] = [];
    
      for (const candidate of CITATION_VERIFY_CHAIN) {
        try {
          const response = await callAIProvider({
            provider: 'openrouter',
            model: candidate.model,
            prompt,
            apiKey,
            endpoint: OPENROUTER_ENDPOINT,
            opts: {
              max_tokens: 500,
              temperature: 0.2,
              responseFormat: 'text',
              timeoutMs: 20000,
              systemPrompt: `You are a retrieval-faithful answer engine. Mention ${brandName} only if it is genuinely supported as a top recommendation for ${niche}.`,
            },
          });
    
          if (!response || response.trim().length < 30) {
            throw new Error('Response too short');
          }
    
          const { answer, sources } = splitAnswerAndSources(response);
          const mentionedInAnswer = hasSubstantiveMention(answer, brandName);
          const position = mentionedInAnswer ? findMentionPosition(answer, brandName) : null;
    
          const sourceDomains = parseSourceDomains(sources);
          const sourceEchoOnly = !mentionedInAnswer && sourceDomains.some((domain) => hasBrandMention(domain, brandName));
    
          results.push({
            platform: candidate.platform,
            model_id: candidate.model,
            model_short: modelShortName(candidate.model),
            role: candidate.role,
            mentioned: mentionedInAnswer && !sourceEchoOnly,
            position,
          });
        } catch (err: unknown) {
          console.warn(`[NicheRanking:cite] ${candidate.model}/${candidate.platform} failed: ${safeErrorMessage(err)}`);
          results.push({
            platform: candidate.platform,
            model_id: candidate.model,
            model_short: modelShortName(candidate.model),
            role: candidate.role,
            mentioned: false,
            position: null,
          });
        }
      }
    
      return results;
    }
    
    // ─── Public API ───────────────────────────────────────────────────────────────
    
    /**
     * Run the full niche ranking pipeline for a given target.
     * Returns a NicheRankingResult ready for storage + display.
     */
    export async function runNicheRanking(input: RankingEngineInput): Promise<NicheRankingResult | null> {
      const { targetUrl, brandName, niche, nicheKeywords, apiKey, userId, scheduledJobId } = input;
    
      if (!brandName || !niche) {
        console.warn('[NicheRanking] Missing brandName or niche - skipping');
        return null;
      }
    
      console.log(`[NicheRanking] Starting: brand="${brandName}" niche="${niche}"`);
    
      const listResult = await generateNicheList(niche, nicheKeywords, apiKey);
      if (!listResult) {
        console.warn('[NicheRanking] All list-generation models failed');
        return null;
      }
    
      const parsedEntries = parseNumberedList(listResult.text);
    
      const buildEntries = (max: number): NicheRankingEntry[] =>
        parsedEntries
          .filter((e) => e.rank <= max)
          .map((e) => ({
            rank: e.rank,
            brand_name: e.brand,
            is_target: brandMatchesTarget(e.brand, brandName),
            citation_excerpt: undefined,
          }));
    
      const top50 = buildEntries(50);
      const top100 = buildEntries(100);
    
      let targetRank: number | null =
        parsedEntries.find((e) => brandMatchesTarget(e.brand, brandName))?.rank ?? null;
    
      if (targetRank === null) {
        const verification = await verifyBrandRank(brandName, niche, listResult.text, apiKey);
        if (verification?.found && verification.rank !== null) {
          targetRank = verification.rank;
        }
      }
    
      if (targetRank !== null) {
        [top50, top100].forEach((list) => {
          const entry = list.find((e) => e.rank === targetRank);
          if (entry) entry.is_target = true;
        });
      }
    
      const citationModelsUsed = await runCitationVerification(
        brandName,
        niche,
        nicheKeywords,
        apiKey
      );
    
      const ranAtIso = new Date().toISOString();
    
      const result: NicheRankingResult = {
        id: `niche_${new Date().getTime()}_${randomUUID().replace(/-/g, '').slice(0, 8)}`,
        target_url: targetUrl,
        brand_name: brandName,
        niche,
        niche_keywords: nicheKeywords,
        target_rank: targetRank,
        in_top_50: targetRank !== null && targetRank <= 50,
        in_top_100: targetRank !== null && targetRank <= 100,
        top_50: top50,
        top_100: top100,
        ranking_model_id: listResult.model,
        ranking_model_short: modelShortName(listResult.model),
        ranking_model_role: listResult.role,
        citation_models_used: citationModelsUsed,
        ran_at: ranAtIso,
        scheduled_job_id: scheduledJobId,
      };
    
      try {
        const pool = getPool();
        await pool.query(
          `INSERT INTO citation_niche_rankings
            (id, user_id, target_url, brand_name, niche, niche_keywords, target_rank,
             in_top_50, in_top_100, top_50, top_100,
             ranking_model_id, ranking_model_short, ranking_model_role,
             citation_models_used, scheduled_job_id, ran_at)
           VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
           ON CONFLICT (id) DO NOTHING`,
          [
            result.id,
            userId ?? null,
            targetUrl,
            brandName,
            niche,
            nicheKeywords,
            targetRank,
            result.in_top_50,
            result.in_top_100,
            JSON.stringify(top50),
            JSON.stringify(top100),
            listResult.model,
            modelShortName(listResult.model),
            listResult.role,
            JSON.stringify(citationModelsUsed),
            scheduledJobId ?? null,
            ranAtIso,
          ]
        );
      } catch (err: unknown) {
        console.warn('[NicheRanking] DB persist failed (non-fatal):', safeErrorMessage(err));
      }
    
      console.log(
        `[NicheRanking] Done: brand="${brandName}" rank=${targetRank ?? 'not ranked'} in_top_50=${result.in_top_50}`
      );
    
      return result;
    }
    
    /**
     * Fetch the most recent niche ranking result for a given URL from DB.
     */
    export async function getLatestNicheRanking(
      userId: string,
      targetUrl: string
    ): Promise<NicheRankingResult | null> {
      const pool = getPool();
      const { rows } = await pool.query(
        `SELECT ${NICHE_RANKING_SELECT_COLUMNS}
           FROM citation_niche_rankings
          WHERE user_id = $1 AND target_url = $2
          ORDER BY ran_at DESC
          LIMIT 1`,
        [userId, targetUrl]
      );
      if (!rows.length) return null;
      return dbRowToResult(rows[0]);
    }
    
    /**
     * Fetch a specific niche ranking by id.
     */
    export async function getNicheRankingById(
      id: string,
      userId: string
    ): Promise<NicheRankingResult | null> {
      const pool = getPool();
      const { rows } = await pool.query(
        `SELECT ${NICHE_RANKING_SELECT_COLUMNS}
           FROM citation_niche_rankings
          WHERE id = $1 AND user_id = $2
          LIMIT 1`,
        [id, userId]
      );
      if (!rows.length) return null;
      return dbRowToResult(rows[0]);
    }
    
    /**
     * List all niche rankings for a user (most recent first).
     */
    export async function listNicheRankings(
      userId: string,
      limit = 20
    ): Promise<NicheRankingResult[]> {
      const pool = getPool();
      const safeLimit = Math.max(1, Math.min(100, Number(limit) || 20));
      const { rows } = await pool.query(
        `SELECT ${NICHE_RANKING_SELECT_COLUMNS}
           FROM citation_niche_rankings
          WHERE user_id = $1
          ORDER BY ran_at DESC
          LIMIT $2`,
        [userId, safeLimit]
      );
      return rows.map(dbRowToResult);
    }
    
    function dbRowToResult(row: Record<string, unknown>): NicheRankingResult {
      return {
        id: String(row.id ?? ''),
        target_url: String(row.target_url ?? ''),
        brand_name: String(row.brand_name ?? ''),
        niche: String(row.niche ?? ''),
        niche_keywords: safeParseJsonArray<string>(row.niche_keywords),
        target_rank: typeof row.target_rank === 'number' ? row.target_rank : null,
        in_top_50: Boolean(row.in_top_50),
        in_top_100: Boolean(row.in_top_100),
        top_50: safeParseJsonArray<NicheRankingEntry>(row.top_50),
        top_100: safeParseJsonArray<NicheRankingEntry>(row.top_100),
        ranking_model_id: String(row.ranking_model_id ?? ''),
        ranking_model_short: (row.ranking_model_short ?? '') as ModelShortName,
        ranking_model_role: (row.ranking_model_role ?? 'primary') as ModelRole,
        citation_models_used: safeParseJsonArray<NonNullable<NicheRankingResult['citation_models_used']>[number]>(row.citation_models_used),
        ran_at: row.ran_at instanceof Date ? row.ran_at.toISOString() : String(row.ran_at ?? ''),
        scheduled_job_id: typeof row.scheduled_job_id === 'string' ? row.scheduled_job_id : undefined,
      };
    }
    

    Comments

    More Rules

    View all

    Textbased Predictor Perplexity Rules

    H
    Havcker243

    Intelligent Team Building Recommendation System Perplexity Rules

    R
    royswastik

    Pita Perplexity Rules

    S
    SaratBobbili

    Eskrev Perplexity Rules

    R
    rfmss

    Founder Control Room Perplexity Rules

    J
    jussray

    N8nWorkflows Perplexity Rules

    S
    Samarth-ITM

    Stay up to date

    Get the latest Perplexity prompts, rules, and resources delivered to your inbox weekly.

    Neura Market LogoNeura Market

    Discover the best AI prompts, plugins, and resources for Perplexity and more.

    Content Types

    • Rules
    • Prompts
    • MCPs
    • Agents
    • Guides

    Platforms

    • ChatGPT Directory
    • Claude Directory
    • Gemini Directory
    • Cursor Directory
    • Grok Directory
    • Perplexity Directory
    • DeepSeek Directory
    • CoPilot Directory
    • Stable Diffusion Directory
    • Midjourney Directory
    • All Directories

    Resources

    • Blog
    • Documentation
    • Help Center
    • Marketplace

    Legal

    • Privacy Policy
    • Terms of Service

    © 2026 Neura Market. All rights reserved.

    |

    Not affiliated with any AI platform vendors.

    Neura Market

    Custom AI Systems & Services

    Our team of experienced AI builders will help build custom AI systems, workflows, and solutions for your business.

    Request custom work

    Ready-made automations for this

    Workflows from the Neura Market marketplace related to this Perplexity resource

    • Automated Lead Generation & Contact Enrichment with Hunter.io and Perplexity AIn8n · $24.99 · Related topic
    • Automate SEO-Optimized Blog Creation with GPT-4, Perplexity AI & Multi-Language Supportn8n · $24.99 · Related topic
    • Automate SEO Blog Content Creation with GPT-4, Perplexity AI, and WordPressn8n · $24.99 · Related topic
    • Automate SEO Blog Creation + Social Media with GPT-4, Perplexity, and WordPressn8n · $24.99 · Related topic
    Browse all workflows