@eternify-labs/seo-audit 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,70 +1,82 @@
1
1
  #!/usr/bin/env node
2
- import{a as Le,c as Te}from"./chunk-KQB6L3WV.js";import{d as De,e as Me,f as _e,g as Ne}from"./chunk-OSBRK5KC.js";import{a as Z}from"./chunk-O3BX4SMT.js";import{c as Fe}from"./chunk-BSOQNREQ.js";import{a as Ge,b as qe}from"./chunk-5J5XNNV6.js";import"./chunk-O7EAZALL.js";import{a as Oe,b as F,c as B,e as ee,f as M,g as _,i as Je,j as He}from"./chunk-MQJGARAM.js";import{d as Re,e as Ce,f as Ie,g as je,h as Ee}from"./chunk-AIOAF3VX.js";import Dr from"fs";import Mr from"path";import{Command as _r}from"commander";import S from"picocolors";import Nr from"open";import Tt from"fs";import Dt from"path";import T from"picocolors";import Ue from"fs";import Be from"path";import Ke from"os";import{query as te}from"@anthropic-ai/claude-agent-sdk";var Rt={claude:"claude-haiku-4-5",gemini:"gemini-2.5-flash",openai:void 0};function K(n,e,t="standard"){return t==="light"&&e.economy!==!1?e.lightModel??Rt[n]??e.model:e.model}function ne(){let n=[];return{stderr:e=>{n.push(e),n.length>200&&n.shift()},describe(e){let t=n.join("").trim().split(`
3
- `).slice(-15).join(`
4
- `).trim();return t?`${e}
2
+ import{a as Je,c as He}from"./chunk-KQB6L3WV.js";import{d as Ue,e as Be,f as Ke,g as ze}from"./chunk-4B57MNCM.js";import{a as oe}from"./chunk-SFOFTWOZ.js";import{c as We}from"./chunk-BSOQNREQ.js";import{a as Ve,b as Qe}from"./chunk-5J5XNNV6.js";import"./chunk-O7EAZALL.js";import{a as ye,b as qe,c as M,d as B,f as ie,g as N,h as D,j as Ye,k as Xe}from"./chunk-SDQQ4ENP.js";import{d as Me,e as Ne,f as De,g as Fe,h as Ge}from"./chunk-QCEC2HDC.js";import go from"fs";import mo from"path";import{Command as ho}from"commander";import v from"picocolors";import fo from"open";import rn from"fs";import on from"path";import T from"picocolors";import Ze from"fs";import et from"path";import tt from"os";import{spawn as Ht}from"child_process";import{query as we}from"@anthropic-ai/claude-agent-sdk";var Jt={claude:"claude-haiku-4-5",gemini:"gemini-2.5-flash",openai:void 0};function K(n,e,t="standard"){return t==="light"&&e.economy!==!1?e.lightModel??Jt[n]??e.model:e.model}var nt=300,Ut=3e3,Bt=/authentication_error|Please run \/login/i,Kt="Claude rejected your login, which usually means it expired. Open a terminal, run `claude`, type /login and sign in, then try again. If Claude Code isn't installed, run `npx @anthropic-ai/claude-code` instead. To use an API key, run `pulse-seo login`.";function be(){let n=[];return{stderr:e=>{n.push(e),n.length>200&&n.shift()},describe(e){let t=Bt.test(e)?`${Kt}
3
+ ${e}`:e,o=n.join("").trim().split(`
4
+ `).slice(-15).map(i=>i.length>nt?`${i.slice(0,nt)}\u2026`:i).join(`
5
+ `).slice(-Ut).trim();return o?`${t}
5
6
  Claude CLI output:
6
- ${t}`:e}}}var re=class{constructor(e){this.config=e}config;id="claude";label="Claude";env(){let e={...process.env};return this.config.anthropicApiKey&&(e.ANTHROPIC_API_KEY=this.config.anthropicApiKey),e}authStatus(){if(this.config.anthropicApiKey||process.env.ANTHROPIC_API_KEY)return{method:"api-key",detail:"Using Anthropic API key"};let e=Be.join(Ke.homedir(),".claude",".credentials.json");return Ue.existsSync(e)||Ue.existsSync(Be.join(Ke.homedir(),".claude.json"))?{method:"subscription",detail:"Reusing your Claude Code login"}:{method:"unknown",detail:"No Claude credentials detected yet"}}async verify(){let e=ne();try{for await(let t of te({prompt:"Reply with exactly the single word: ok",options:{model:this.config.model,tools:[],maxTurns:1,persistSession:!1,permissionMode:"dontAsk",env:this.env(),stderr:e.stderr}}))if(t.type==="result")return t.subtype==="success"?{ok:!0,detail:`Connected (model responded in ${t.duration_ms}ms)`}:{ok:!1,detail:e.describe(`Claude returned ${t.subtype}`)};return{ok:!1,detail:"No result from Claude"}}catch(t){return{ok:!1,detail:e.describe(t instanceof Error?t.message:String(t))}}}async analyzeJson(e){let t=ne();try{for await(let r of te({prompt:`${e.system}
7
+ ${o}`:t}}}var zt=25,Wt=60,Vt=200,rt=3;function ve(n,e,t){let r=[...e.errors??[],e.result,t].find(s=>s?.trim()),o=e.subtype==="success"?"ended without a usable answer":e.subtype,i=e.num_turns?` after ${e.num_turns} turns`:"";return`${n}: ${o}${i}${r?`: ${r.trim()}`:""}`}function Qt(n){return({command:e,args:t,cwd:r,env:o,signal:i})=>{let s=Ht(e,t,{cwd:r,env:o,signal:i,killSignal:"SIGKILL",stdio:["pipe","pipe","pipe"],windowsHide:!0});return s.stderr.on("data",a=>n(String(a))),s}}var se=class{constructor(e){this.config=e}config;id="claude";label="Claude";env(){let e={...process.env};return this.config.anthropicApiKey&&(e.ANTHROPIC_API_KEY=this.config.anthropicApiKey),e}authStatus(){if(this.config.anthropicApiKey||process.env.ANTHROPIC_API_KEY)return{method:"api-key",detail:"Using Anthropic API key"};let e=et.join(tt.homedir(),".claude",".credentials.json");return Ze.existsSync(e)||Ze.existsSync(et.join(tt.homedir(),".claude.json"))?{method:"subscription",detail:"Reusing your Claude Code login"}:{method:"unknown",detail:"No Claude credentials detected yet"}}async verify(){let e=be();try{for await(let t of we({prompt:"Reply with exactly the single word: ok",options:{model:this.config.model,tools:[],maxTurns:1,persistSession:!1,permissionMode:"dontAsk",env:this.env(),stderr:e.stderr}}))if(t.type==="result")return t.subtype==="success"&&!t.is_error?{ok:!0,detail:`Connected (model responded in ${t.duration_ms}ms)`}:{ok:!1,detail:e.describe(ve("Claude check failed",t,""))};return{ok:!1,detail:"No result from Claude"}}catch(t){return{ok:!1,detail:e.describe(t instanceof Error?t.message:String(t))}}}async runStructured(e,t,r,o){let i=be(),s="",a=0,d=new AbortController;try{for await(let m of we({prompt:r,options:{...t,abortController:d,spawnClaudeCodeProcess:Qt(i.stderr)}}))if(m.type==="assistant"){let y=!1;for(let p of m.message.content)if(p.type==="text"&&p.text.trim())s=p.text;else if(p.type==="tool_use"){y=!0;let g=p.input,c=typeof g?.file_path=="string"?` ${g.file_path}`:typeof g?.pattern=="string"?` ${g.pattern}`:"";o?.({kind:"tool",message:`${p.name}${c}`})}if(m.error)throw new Error(`${e}: ${s.trim()||m.error}`);if(a=y?0:a+1,a>=rt)throw new Error(`${e}: the model stopped ${rt} times without returning structured output${s?`. Last reply: ${s.trim()}`:""}`)}else if(m.type==="result"){if(m.subtype==="success"&&!m.is_error&&m.structured_output!==void 0)return m.structured_output;throw new Error(ve(e,m,s))}throw new Error(`${e}: produced no result`)}catch(m){throw new Error(i.describe(m instanceof Error?m.message:String(m)))}finally{d.abort()}}async analyzeJson(e){return this.runStructured("Claude analysis failed",{model:K("claude",this.config,e.tier),tools:e.webSearch?["WebSearch","WebFetch"]:[],allowedTools:e.webSearch?["WebSearch","WebFetch"]:void 0,maxTurns:e.webSearch?Wt:zt,persistSession:!1,permissionMode:"dontAsk",env:this.env(),outputFormat:{type:"json_schema",schema:e.jsonSchema}},`${e.system}
7
8
 
8
- ${e.prompt}`,options:{model:K("claude",this.config,e.tier),tools:e.webSearch?["WebSearch","WebFetch"]:[],allowedTools:e.webSearch?["WebSearch","WebFetch"]:void 0,persistSession:!1,permissionMode:"dontAsk",env:this.env(),outputFormat:{type:"json_schema",schema:e.jsonSchema},stderr:t.stderr}}))if(r.type==="result"){if(r.subtype==="success"&&r.structured_output!==void 0)return r.structured_output;throw new Error(`Claude analysis failed: ${r.subtype}`)}throw new Error("Claude analysis produced no result")}catch(r){throw new Error(t.describe(r instanceof Error?r.message:String(r)))}}async exploreJson(e){let t=ne();try{for await(let r of te({prompt:`${e.system}
9
+ ${e.prompt}`)}async exploreJson(e){return this.runStructured("Claude exploration failed",{model:this.config.model,cwd:e.cwd,tools:["Read","Glob","Grep"],allowedTools:["Read","Glob","Grep"],maxTurns:Vt,permissionMode:"dontAsk",persistSession:!1,env:this.env(),outputFormat:{type:"json_schema",schema:e.jsonSchema}},`${e.system}
9
10
 
10
- ${e.prompt}`,options:{model:this.config.model,cwd:e.cwd,tools:["Read","Glob","Grep"],allowedTools:["Read","Glob","Grep"],permissionMode:"dontAsk",persistSession:!1,env:this.env(),outputFormat:{type:"json_schema",schema:e.jsonSchema},stderr:t.stderr}}))if(r.type==="assistant"){for(let o of r.message.content)if(o.type==="tool_use"){let i=o.input,s=typeof i?.file_path=="string"?` ${i.file_path}`:typeof i?.pattern=="string"?` ${i.pattern}`:"";e.onEvent?.({kind:"tool",message:`${o.name}${s}`})}}else if(r.type==="result"){if(r.subtype==="success"&&r.structured_output!==void 0)return r.structured_output;throw new Error(`Claude exploration failed: ${r.subtype}`)}throw new Error("Claude exploration produced no result")}catch(r){throw new Error(t.describe(r instanceof Error?r.message:String(r)))}}async runAgent(e){let t=i=>e.onEvent?.(i),r=ne(),o="";try{for await(let i of te({prompt:e.prompt,options:{model:this.config.model,cwd:e.cwd,tools:["Read","Edit","Write","Glob","Grep"],allowedTools:["Read","Edit","Write","Glob","Grep"],permissionMode:"dontAsk",persistSession:!1,env:this.env(),stderr:r.stderr}}))if(i.type==="assistant"){for(let s of i.message.content)if(s.type==="text"&&s.text.trim())t({kind:"text",message:s.text.trim()});else if(s.type==="tool_use"){let a=s.input,d=typeof a?.file_path=="string"?` ${a.file_path}`:"";t({kind:"tool",message:`${s.name}${d}`})}}else if(i.type==="result")if(i.subtype==="success")o=i.result;else throw new Error(`Claude agent failed: ${i.subtype}`);return o}catch(i){throw new Error(r.describe(i instanceof Error?i.message:String(i)))}}};import ze from"fs";import he from"path";import me from"os";import{createRequire as Ct}from"module";import{Codex as It}from"@openai/codex-sdk";var jt=Ct(import.meta.url);function fe(){let e={"darwin-arm64":"aarch64-apple-darwin","darwin-x64":"x86_64-apple-darwin","linux-arm64":"aarch64-unknown-linux-musl","linux-x64":"x86_64-unknown-linux-musl","win32-arm64":"aarch64-pc-windows-msvc","win32-x64":"x86_64-pc-windows-msvc"}[`${process.platform}-${process.arch}`];if(!e)return null;try{let t=he.dirname(jt.resolve("@openai/codex-sdk/package.json")),r=he.join(t,"vendor",e,"codex",process.platform==="win32"?"codex.exe":"codex");return ze.existsSync(r)?r:null}catch{return null}}var oe=class{constructor(e){this.config=e}config;id="openai";label="OpenAI";client(){let e=this.config.openaiApiKey??process.env.OPENAI_API_KEY;return new It(e?{apiKey:e}:{})}authStatus(){return this.config.openaiApiKey||process.env.OPENAI_API_KEY?{method:"api-key",detail:"Using OpenAI API key"}:ze.existsSync(he.join(me.homedir(),".codex","auth.json"))?{method:"subscription",detail:"Reusing your Codex / ChatGPT login"}:{method:"unknown",detail:"No OpenAI credentials detected yet"}}threadDefaults(){return{model:this.config.model,skipGitRepoCheck:!0}}async verify(){try{let e=this.client().startThread({...this.threadDefaults(),sandboxMode:"read-only",workingDirectory:me.tmpdir()}),t=Date.now();return(await e.run("Reply with exactly the single word: ok")).finalResponse?{ok:!0,detail:`Connected (model responded in ${Date.now()-t}ms)`}:{ok:!1,detail:"Empty response from OpenAI"}}catch(e){return{ok:!1,detail:e instanceof Error?e.message:String(e)}}}async analyzeJson(e){let o=(await this.client().startThread({...this.threadDefaults(),model:K("openai",this.config,e.tier),sandboxMode:"read-only",workingDirectory:me.tmpdir()}).run(`${e.system}
11
+ ${e.prompt}`,e.onEvent)}async runAgent(e){let t=i=>e.onEvent?.(i),r=be(),o="";try{for await(let i of we({prompt:e.prompt,options:{model:this.config.model,cwd:e.cwd,tools:["Read","Edit","Write","Glob","Grep"],allowedTools:["Read","Edit","Write","Glob","Grep"],permissionMode:"dontAsk",persistSession:!1,env:this.env(),stderr:r.stderr}}))if(i.type==="assistant"){for(let s of i.message.content)if(s.type==="text"&&s.text.trim())t({kind:"text",message:s.text.trim()});else if(s.type==="tool_use"){let a=s.input,d=typeof a?.file_path=="string"?` ${a.file_path}`:"";t({kind:"tool",message:`${s.name}${d}`})}}else if(i.type==="result")if(i.subtype==="success"&&!i.is_error)o=i.result;else throw new Error(ve("Claude agent failed",i,""));return o}catch(i){throw new Error(r.describe(i instanceof Error?i.message:String(i)))}}};import ot from"fs";import Ae from"path";import ke from"os";import{createRequire as Yt}from"module";import{Codex as Xt}from"@openai/codex-sdk";var Zt=Yt(import.meta.url);function Se(){let e={"darwin-arm64":"aarch64-apple-darwin","darwin-x64":"x86_64-apple-darwin","linux-arm64":"aarch64-unknown-linux-musl","linux-x64":"x86_64-unknown-linux-musl","win32-arm64":"aarch64-pc-windows-msvc","win32-x64":"x86_64-pc-windows-msvc"}[`${process.platform}-${process.arch}`];if(!e)return null;try{let t=Ae.dirname(Zt.resolve("@openai/codex-sdk/package.json")),r=Ae.join(t,"vendor",e,"codex",process.platform==="win32"?"codex.exe":"codex");return ot.existsSync(r)?r:null}catch{return null}}var ae=class{constructor(e){this.config=e}config;id="openai";label="OpenAI";client(){let e=this.config.openaiApiKey??process.env.OPENAI_API_KEY;return new Xt(e?{apiKey:e}:{})}authStatus(){return this.config.openaiApiKey||process.env.OPENAI_API_KEY?{method:"api-key",detail:"Using OpenAI API key"}:ot.existsSync(Ae.join(ke.homedir(),".codex","auth.json"))?{method:"subscription",detail:"Reusing your Codex / ChatGPT login"}:{method:"unknown",detail:"No OpenAI credentials detected yet"}}threadDefaults(){return{model:this.config.model,skipGitRepoCheck:!0}}async verify(){try{let e=this.client().startThread({...this.threadDefaults(),sandboxMode:"read-only",workingDirectory:ke.tmpdir()}),t=Date.now();return(await e.run("Reply with exactly the single word: ok")).finalResponse?{ok:!0,detail:`Connected (model responded in ${Date.now()-t}ms)`}:{ok:!1,detail:"Empty response from OpenAI"}}catch(e){return{ok:!1,detail:e instanceof Error?e.message:String(e)}}}async analyzeJson(e){let o=(await this.client().startThread({...this.threadDefaults(),model:K("openai",this.config,e.tier),sandboxMode:"read-only",workingDirectory:ke.tmpdir()}).run(`${e.system}
11
12
 
12
13
  ${e.prompt}`,{outputSchema:e.jsonSchema})).finalResponse?.trim()??"",i=o.replace(/^```(?:json)?\s*/i,"").replace(/```\s*$/,"");try{return JSON.parse(i)}catch{throw new Error(`OpenAI analysis returned non-JSON output: ${o.slice(0,200)}`)}}async exploreJson(e){let o=(await this.client().startThread({...this.threadDefaults(),sandboxMode:"read-only",workingDirectory:e.cwd}).run(`${e.system}
13
14
 
14
- ${e.prompt}`,{outputSchema:e.jsonSchema})).finalResponse?.trim()??"",i=o.replace(/^```(?:json)?\s*/i,"").replace(/```\s*$/,"");try{return JSON.parse(i)}catch{throw new Error(`OpenAI exploration returned non-JSON output: ${o.slice(0,200)}`)}}async runAgent(e){let t=this.client().startThread({...this.threadDefaults(),sandboxMode:"workspace-write",workingDirectory:e.cwd}),r="",{events:o}=await t.runStreamed(e.prompt);for await(let i of o)if(i.type==="item.completed"){let s=i.item;if(s.type==="agent_message"&&s.text.trim())r=s.text.trim(),e.onEvent?.({kind:"text",message:r});else if(s.type==="command_execution")e.onEvent?.({kind:"tool",message:`$ ${s.command}`});else if(s.type==="file_change"){let a=s.changes.map(d=>`${d.kind} ${d.path}`).join(", ");e.onEvent?.({kind:"tool",message:`edit: ${a}`})}else s.type==="error"&&e.onEvent?.({kind:"text",message:`error: ${s.message}`})}else{if(i.type==="turn.failed")throw new Error(`OpenAI agent failed: ${i.error.message}`);if(i.type==="error")throw new Error(`OpenAI agent stream error: ${i.message}`)}return r}};import We from"fs";import we from"path";import ye from"os";import{spawn as Et}from"child_process";import{createRequire as Ot}from"module";var Lt=Ot(import.meta.url);function se(){try{let n=we.dirname(Lt.resolve("@google/gemini-cli/package.json")),e=we.join(n,"bundle","gemini.js");return We.existsSync(e)?e:null}catch{return null}}var ie=class n{constructor(e){this.config=e}config;id="gemini";label="Gemini";env(){let e={...process.env};return this.config.geminiApiKey&&(e.GEMINI_API_KEY=this.config.geminiApiKey),e}authStatus(){return this.config.geminiApiKey||process.env.GEMINI_API_KEY?{method:"api-key",detail:"Using Gemini API key"}:We.existsSync(we.join(ye.homedir(),".gemini","oauth_creds.json"))?{method:"subscription",detail:"Reusing your Gemini CLI Google login"}:{method:"unknown",detail:"No Gemini credentials detected yet"}}run(e,t){let r=se();if(!r)return Promise.reject(new Error("Bundled Gemini CLI not found \u2014 reinstall the package."));let o=[r,"-p",e,"--output-format","json"],i=t.model??this.config.model;return i&&o.push("-m",i),t.approvalMode&&o.push("--approval-mode",t.approvalMode),new Promise((s,a)=>{let d=Et(process.execPath,o,{cwd:t.cwd,env:this.env(),stdio:["ignore","pipe","pipe"]}),m="",w="",u=setTimeout(()=>{d.kill("SIGKILL"),a(new Error("Gemini CLI timed out"))},t.timeoutMs??6e5);d.stdout.on("data",g=>m+=g),d.stderr.on("data",g=>w+=g),d.on("error",g=>{clearTimeout(u),a(g)}),d.on("close",g=>{clearTimeout(u);let l=m+w;if(/Auth method|GEMINI_API_KEY|not authenticated/i.test(l)&&g!==0)return a(new Error("Gemini is not signed in \u2014 run `pulse-seo login` to sign in with Google, or add a GEMINI_API_KEY."));try{let c=JSON.parse(m.slice(m.indexOf("{")));return c.error?.message?a(new Error(`Gemini: ${c.error.message}`)):s({text:(c.response??"").trim(),raw:m,code:g??0})}catch{return g===0&&m.trim()?s({text:m.trim(),raw:m,code:0}):a(new Error(`Gemini CLI exited ${g}: ${(w||m).slice(0,300)}`))}})})}static extractJson(e){let t=e.replace(/^```(?:json)?\s*/i,"").replace(/```\s*$/,"").trim();try{return JSON.parse(t)}catch{let r=t.indexOf("{"),o=t.lastIndexOf("}");if(r>=0&&o>r)return JSON.parse(t.slice(r,o+1));throw new Error(`Gemini returned non-JSON output: ${e.slice(0,200)}`)}}async verify(){try{let e=Date.now();return(await this.run("Reply with exactly the single word: ok",{cwd:ye.tmpdir(),timeoutMs:12e4})).text?{ok:!0,detail:`Connected (model responded in ${Date.now()-e}ms)`}:{ok:!1,detail:"Empty response from Gemini"}}catch(e){return{ok:!1,detail:e instanceof Error?e.message:String(e)}}}schemaInstruction(e){return`
15
+ ${e.prompt}`,{outputSchema:e.jsonSchema})).finalResponse?.trim()??"",i=o.replace(/^```(?:json)?\s*/i,"").replace(/```\s*$/,"");try{return JSON.parse(i)}catch{throw new Error(`OpenAI exploration returned non-JSON output: ${o.slice(0,200)}`)}}async runAgent(e){let t=this.client().startThread({...this.threadDefaults(),sandboxMode:"workspace-write",workingDirectory:e.cwd}),r="",{events:o}=await t.runStreamed(e.prompt);for await(let i of o)if(i.type==="item.completed"){let s=i.item;if(s.type==="agent_message"&&s.text.trim())r=s.text.trim(),e.onEvent?.({kind:"text",message:r});else if(s.type==="command_execution")e.onEvent?.({kind:"tool",message:`$ ${s.command}`});else if(s.type==="file_change"){let a=s.changes.map(d=>`${d.kind} ${d.path}`).join(", ");e.onEvent?.({kind:"tool",message:`edit: ${a}`})}else s.type==="error"&&e.onEvent?.({kind:"text",message:`error: ${s.message}`})}else{if(i.type==="turn.failed")throw new Error(`OpenAI agent failed: ${i.error.message}`);if(i.type==="error")throw new Error(`OpenAI agent stream error: ${i.message}`)}return r}};import it from"fs";import Pe from"path";import xe from"os";import{spawn as en}from"child_process";import{createRequire as tn}from"module";var nn=tn(import.meta.url);function le(){try{let n=Pe.dirname(nn.resolve("@google/gemini-cli/package.json")),e=Pe.join(n,"bundle","gemini.js");return it.existsSync(e)?e:null}catch{return null}}var ce=class n{constructor(e){this.config=e}config;id="gemini";label="Gemini";env(){let e={...process.env};return this.config.geminiApiKey&&(e.GEMINI_API_KEY=this.config.geminiApiKey),e}authStatus(){return this.config.geminiApiKey||process.env.GEMINI_API_KEY?{method:"api-key",detail:"Using Gemini API key"}:it.existsSync(Pe.join(xe.homedir(),".gemini","oauth_creds.json"))?{method:"subscription",detail:"Reusing your Gemini CLI Google login"}:{method:"unknown",detail:"No Gemini credentials detected yet"}}run(e,t){let r=le();if(!r)return Promise.reject(new Error("Bundled Gemini CLI not found \u2014 reinstall the package."));let o=[r,"-p",e,"--output-format","json"],i=t.model??this.config.model;return i&&o.push("-m",i),t.approvalMode&&o.push("--approval-mode",t.approvalMode),new Promise((s,a)=>{let d=en(process.execPath,o,{cwd:t.cwd,env:this.env(),stdio:["ignore","pipe","pipe"]}),m="",y="",p=setTimeout(()=>{d.kill("SIGKILL"),a(new Error("Gemini CLI timed out"))},t.timeoutMs??6e5);d.stdout.on("data",g=>m+=g),d.stderr.on("data",g=>y+=g),d.on("error",g=>{clearTimeout(p),a(g)}),d.on("close",g=>{clearTimeout(p);let c=m+y;if(/Auth method|GEMINI_API_KEY|not authenticated/i.test(c)&&g!==0)return a(new Error("Gemini is not signed in \u2014 run `pulse-seo login` to sign in with Google, or add a GEMINI_API_KEY."));try{let l=JSON.parse(m.slice(m.indexOf("{")));return l.error?.message?a(new Error(`Gemini: ${l.error.message}`)):s({text:(l.response??"").trim(),raw:m,code:g??0})}catch{return g===0&&m.trim()?s({text:m.trim(),raw:m,code:0}):a(new Error(`Gemini CLI exited ${g}: ${(y||m).slice(0,300)}`))}})})}static extractJson(e){let t=e.replace(/^```(?:json)?\s*/i,"").replace(/```\s*$/,"").trim();try{return JSON.parse(t)}catch{let r=t.indexOf("{"),o=t.lastIndexOf("}");if(r>=0&&o>r)return JSON.parse(t.slice(r,o+1));throw new Error(`Gemini returned non-JSON output: ${e.slice(0,200)}`)}}async verify(){try{let e=Date.now();return(await this.run("Reply with exactly the single word: ok",{cwd:xe.tmpdir(),timeoutMs:12e4})).text?{ok:!0,detail:`Connected (model responded in ${Date.now()-e}ms)`}:{ok:!1,detail:"Empty response from Gemini"}}catch(e){return{ok:!1,detail:e instanceof Error?e.message:String(e)}}}schemaInstruction(e){return`
15
16
 
16
17
  Respond ONLY with a single JSON object that conforms exactly to this JSON Schema \u2014 no markdown fences, no commentary:
17
18
  ${JSON.stringify(e)}`}async analyzeJson(e){let t=await this.run(`${e.system}
18
19
 
19
- ${e.prompt}${this.schemaInstruction(e.jsonSchema)}`,{cwd:ye.tmpdir(),model:K("gemini",this.config,e.tier)});return n.extractJson(t.text)}async exploreJson(e){e.onEvent?.({kind:"tool",message:"gemini exploring (read-only headless run)"});let t=await this.run(`${e.system}
20
+ ${e.prompt}${this.schemaInstruction(e.jsonSchema)}`,{cwd:xe.tmpdir(),model:K("gemini",this.config,e.tier)});return n.extractJson(t.text)}async exploreJson(e){e.onEvent?.({kind:"tool",message:"gemini exploring (read-only headless run)"});let t=await this.run(`${e.system}
20
21
 
21
- ${e.prompt}${this.schemaInstruction(e.jsonSchema)}`,{cwd:e.cwd,approvalMode:"default"});return n.extractJson(t.text)}async runAgent(e){e.onEvent?.({kind:"tool",message:"gemini applying fix (auto_edit headless run)"});let t=await this.run(e.prompt,{cwd:e.cwd,approvalMode:"auto_edit"});return t.text&&e.onEvent?.({kind:"text",message:t.text.slice(0,500)}),t.text}};function C(n){switch(n.provider){case"claude":return new re(n);case"openai":return new oe(n);case"gemini":return new ie(n)}}function Mt(){let n=[process.env.CLAUDE_CODE_GIT_BASH_PATH,"C:\\Program Files\\Git\\bin\\bash.exe","C:\\Program Files (x86)\\Git\\bin\\bash.exe",process.env.LOCALAPPDATA?Dt.join(process.env.LOCALAPPDATA,"Programs","Git","bin","bash.exe"):void 0].filter(e=>!!e);for(let e of n)try{if(Tt.existsSync(e))return e}catch{}return null}function ae(n,e){if(!e.needsAi)return;let t=[],r=[];if(n.provider==="claude"&&process.platform==="win32"){let i=Mt();i?process.env.CLAUDE_CODE_GIT_BASH_PATH||(process.env.CLAUDE_CODE_GIT_BASH_PATH=i):t.push([`${T.bold("Missing prerequisite: Git Bash")} \u2014 the Claude AI engine on Windows requires it.`,""," Fix (run in a terminal, then CLOSE it and open a new one):",` ${T.cyan("winget install --id Git.Git -e --source winget")}`,` ${T.cyan('setx CLAUDE_CODE_GIT_BASH_PATH "C:\\Program Files\\Git\\bin\\bash.exe"')}`,"",` Or download it from ${T.underline("https://git-scm.com/downloads/win")} (install with defaults).`].join(`
22
+ ${e.prompt}${this.schemaInstruction(e.jsonSchema)}`,{cwd:e.cwd,approvalMode:"default"});return n.extractJson(t.text)}async runAgent(e){e.onEvent?.({kind:"tool",message:"gemini applying fix (auto_edit headless run)"});let t=await this.run(e.prompt,{cwd:e.cwd,approvalMode:"auto_edit"});return t.text&&e.onEvent?.({kind:"text",message:t.text.slice(0,500)}),t.text}};function C(n){switch(n.provider){case"claude":return new se(n);case"openai":return new ae(n);case"gemini":return new ce(n)}}function sn(){let n=[process.env.CLAUDE_CODE_GIT_BASH_PATH,"C:\\Program Files\\Git\\bin\\bash.exe","C:\\Program Files (x86)\\Git\\bin\\bash.exe",process.env.LOCALAPPDATA?on.join(process.env.LOCALAPPDATA,"Programs","Git","bin","bash.exe"):void 0].filter(e=>!!e);for(let e of n)try{if(rn.existsSync(e))return e}catch{}return null}function de(n,e){if(!e.needsAi)return;let t=[],r=[];if(n.provider==="claude"&&process.platform==="win32"){let i=sn();i?process.env.CLAUDE_CODE_GIT_BASH_PATH||(process.env.CLAUDE_CODE_GIT_BASH_PATH=i):t.push([`${T.bold("Missing prerequisite: Git Bash")} \u2014 the Claude AI engine on Windows requires it.`,""," Fix (run in a terminal, then CLOSE it and open a new one):",` ${T.cyan("winget install --id Git.Git -e --source winget")}`,` ${T.cyan('setx CLAUDE_CODE_GIT_BASH_PATH "C:\\Program Files\\Git\\bin\\bash.exe"')}`,"",` Or download it from ${T.underline("https://git-scm.com/downloads/win")} (install with defaults).`].join(`
22
23
  `))}C(n).authStatus().method==="unknown"&&r.push(`${T.bold("No AI credentials detected")} for provider "${n.provider}" \u2014 if the AI rounds fail, run ${T.cyan("pulse-seo login")} to set an API key.`);for(let i of r)console.error(`
23
24
  ${T.yellow("!")} ${i}`);for(let i of t)console.error(`
24
25
  ${e.mode==="strict"?T.red("\u2717"):T.yellow("!")} ${i}`);(r.length>0||t.length>0)&&console.error(""),t.length>0&&e.mode==="strict"&&(console.error(T.dim(` (To run without AI anyway, pass ${T.bold("--no-ai")}.)
25
- `)),process.exit(1))}import{spawn as Ve}from"child_process";import*as k from"@clack/prompts";import z from"picocolors";var _t=[{value:void 0,label:"Default (recommended)",hint:"whatever your Claude login defaults to"},{value:"claude-sonnet-5",label:"Sonnet 5",hint:"best balance for audits"},{value:"claude-opus-5",label:"Opus 5",hint:"deepest analysis, slower"},{value:"claude-haiku-4-5",label:"Haiku 4.5",hint:"fastest, lighter analysis"}],Nt=[{value:void 0,label:"Default (recommended)",hint:"Codex default model"},{value:"gpt-5-codex",label:"GPT-5 Codex",hint:"agentic coding model"}],Ft=[{value:void 0,label:"Default (recommended)",hint:"Gemini CLI default model"},{value:"gemini-2.5-pro",label:"Gemini 2.5 Pro",hint:"deepest analysis"},{value:"gemini-2.5-flash",label:"Gemini 2.5 Flash",hint:"fastest"}];function N(n="Setup cancelled."){k.cancel(n),process.exit(1)}async function Gt(){let n=fe();return n?(k.log.step("Opening ChatGPT sign-in in your browser\u2026"),await new Promise(t=>{let r=Ve(n,["login"],{stdio:"inherit"});r.on("close",o=>t(o??1)),r.on("error",()=>t(1))})===0):(k.log.error("No bundled codex binary for this platform. Install the Codex CLI and run `codex login`, or use an API key."),!1)}async function qt(){let n=se();if(!n)return k.log.error("Bundled Gemini CLI not found \u2014 reinstall the package, or use an API key."),!1;k.log.step("Launching Gemini CLI for Google sign-in. Complete the browser login, then exit Gemini (type /quit or press Ctrl+C).");let e=await new Promise(t=>{let r=Ve(process.execPath,[n],{stdio:"inherit"});r.on("close",o=>t(o??1)),r.on("error",()=>t(1))});return e===0||e===130}async function be(n){k.intro(z.bgCyan(z.black(" Pulse Media SEO/AEO Audit ")));let e=await k.select({message:"Which AI provider do you want to use?",options:[{value:"claude",label:"Claude (Anthropic)",hint:"reuses your Claude Code subscription login"},{value:"openai",label:"OpenAI",hint:"sign in with ChatGPT or use an API key"},{value:"gemini",label:"Gemini (Google)",hint:"sign in with Google \u2014 free tier available"}],initialValue:n?.provider??"claude"});k.isCancel(e)&&N();let t={provider:e,crawl:n?.crawl??{maxPages:25,timeoutMs:1e4,render:!1},performance:n?.performance??!0,economy:n?.economy??!0,lightModel:n?.lightModel,psiApiKey:n?.psiApiKey,dataForSeoLogin:n?.dataForSeoLogin,dataForSeoPassword:n?.dataForSeoPassword,gsc:n?.gsc,anthropicApiKey:n?.anthropicApiKey,openaiApiKey:n?.openaiApiKey,geminiApiKey:n?.geminiApiKey,createdAt:n?.createdAt};if(t.provider==="claude"){let a=C(t).authStatus();if(a.method!=="unknown")k.log.success(`Auth detected: ${a.detail}`),t.authMethod=a.method;else{let d=await k.select({message:"How do you want to authenticate with Claude?",options:[{value:"subscription",label:"Claude subscription (recommended)",hint:"requires Claude Code: run `claude` once to log in"},{value:"api-key",label:"Anthropic API key"}]});if(k.isCancel(d)&&N(),t.authMethod=d,d==="api-key"){let m=await k.password({message:"Paste your Anthropic API key (stored in ~/.pulsemedia/config.json, chmod 600):"});k.isCancel(m)&&N(),t.anthropicApiKey=m.trim()}else k.log.info("If verification fails below, run `claude` in another terminal to log in, then re-run this setup.")}}else if(t.provider==="openai"){let a=C(t).authStatus();if(a.method!=="unknown")k.log.success(`Auth detected: ${a.detail}`),t.authMethod=a.method;else{let d=await k.select({message:"How do you want to authenticate with OpenAI?",options:[{value:"subscription",label:"Sign in with ChatGPT (recommended)",hint:"opens your browser"},{value:"api-key",label:"OpenAI API key"}]});if(k.isCancel(d)&&N(),t.authMethod=d,d==="api-key"){let m=await k.password({message:"Paste your OpenAI API key (stored in ~/.pulsemedia/config.json, chmod 600):"});k.isCancel(m)&&N(),t.openaiApiKey=m.trim()}else await Gt()||k.log.warn("ChatGPT sign-in did not complete \u2014 verification below will tell us where we stand.")}}else{let a=C(t).authStatus();if(a.method!=="unknown")k.log.success(`Auth detected: ${a.detail}`),t.authMethod=a.method;else{let d=await k.select({message:"How do you want to authenticate with Gemini?",options:[{value:"subscription",label:"Sign in with Google (recommended)",hint:"free tier / Google AI Pro \u2014 opens Gemini CLI once"},{value:"api-key",label:"Gemini API key",hint:"from aistudio.google.com"}]});if(k.isCancel(d)&&N(),t.authMethod=d,d==="api-key"){let m=await k.password({message:"Paste your Gemini API key (stored in ~/.pulsemedia/config.json, chmod 600):"});k.isCancel(m)&&N(),t.geminiApiKey=m.trim()}else await qt()||k.log.warn("Google sign-in did not complete \u2014 verification below will tell us where we stand.")}}let r=t.provider==="claude"?_t:t.provider==="openai"?Nt:Ft,o=await k.select({message:"Which model should power the analysis?",options:[...r.map(a=>({value:a.value??"__default",label:a.label,hint:a.hint})),{value:"__custom",label:"Other (type a model ID)"}],initialValue:n?.model??"__default"});if(k.isCancel(o)&&N(),o==="__custom"){let a=await k.text({message:"Model ID:"});k.isCancel(a)&&N(),t.model=a.trim()}else o!=="__default"&&(t.model=o);let i=k.spinner();i.start(`Verifying ${t.provider==="claude"?"Claude":"OpenAI"} connection`);let s=await C(t).verify();if(s.ok)i.stop(z.green(`\u2713 ${s.detail}`));else{i.stop(z.red(`\u2717 ${s.detail}`));let a=await k.confirm({message:"Verification failed. Save this configuration anyway?",initialValue:!1});(k.isCancel(a)||!a)&&N("Setup aborted \u2014 nothing saved.")}return B(t),k.outro(`Configuration saved. ${z.dim("Change it anytime with `pulse-seo login` or in the web UI settings.")}`),t}async function W(){return F()??be()}import X from"fs";import q from"path";import{fileURLToPath as Ir}from"url";import{Hono as jr}from"hono";import{streamSSE as Er}from"hono/streaming";import{serve as At}from"@hono/node-server";import{z as x}from"zod";import{randomUUID as zn}from"crypto";var Jt=0;function y(n,e,t,r,o,i,s,a=s.length||1){return{id:`f-${++Jt}-${n}`,ruleId:n,category:e,severity:t,title:r,description:o,recommendation:i,evidence:s.slice(0,8),occurrences:a,source:"rule"}}var I=n=>n.pages.filter(e=>e.status>=200&&e.status<300);function R(n,e){return I(n).filter(e)}var Ht=["GPTBot","ClaudeBot","Claude-Web","PerplexityBot","Google-Extended","OAI-SearchBot","anthropic-ai"],Ut=n=>{let e=n.pages.filter(t=>t.status>=400);return e.length===0?null:y("http-errors","technical","critical","Pages returning HTTP errors",`${e.length} crawled page(s) returned a 4xx/5xx status. Broken pages waste crawl budget and lose rankings.`,"Fix or redirect the broken URLs, and remove them from the sitemap and internal links.",e.map(t=>({location:t.url,snippet:`HTTP ${t.status}`})),e.length)},Bt=n=>{let e=R(n,t=>!t.title);return e.length===0?null:y("missing-title","technical","critical","Pages without a <title> tag",`${e.length} page(s) have no title tag. The title is the single strongest on-page ranking signal and becomes the clickable headline in search results.`,"Add a unique, descriptive <title> (15\u201360 characters) to every page.",e.map(t=>({location:t.url})),e.length)},Kt=n=>{let e=R(n,t=>!!t.title&&(t.title.length<15||t.title.length>60));return e.length===0?null:y("title-length","technical","warning","Titles too short or too long",`${e.length} page(s) have titles outside the 15\u201360 character range; long ones get truncated in results, short ones waste the slot.`,"Rewrite titles to 15\u201360 characters, front-loading the primary topic.",e.map(t=>({location:t.url,snippet:`"${t.title}" (${t.title.length} chars)`})),e.length)},zt=n=>{let e=new Map;for(let r of R(n,o=>!!o.title)){let o=r.title.toLowerCase();e.set(o,[...e.get(o)??[],r])}let t=[...e.values()].filter(r=>r.length>1);return t.length===0?null:y("duplicate-titles","technical","warning","Duplicate page titles",`${t.length} title(s) are shared by multiple pages, making it hard for search engines to pick the right page for a query.`,"Give every page a unique title describing its specific content.",t.flatMap(r=>r.map(o=>({location:o.url,snippet:o.title}))),t.reduce((r,o)=>r+o.length,0))},Wt=n=>{let e=R(n,t=>!t.metaDescription);return e.length===0?null:y("missing-meta-description","technical","warning","Pages without a meta description",`${e.length} page(s) have no meta description; search engines will improvise a snippet, and answer engines lose a concise summary of the page.`,"Add a 50\u2013160 character meta description that summarizes the page and includes its primary topic.",e.map(t=>({location:t.url})),e.length)},Vt=n=>{let e=R(n,t=>!!t.metaDescription&&(t.metaDescription.length<50||t.metaDescription.length>160));return e.length===0?null:y("meta-description-length","technical","info","Meta descriptions outside 50\u2013160 characters",`${e.length} page(s) have meta descriptions that are too short to be useful or long enough to be truncated.`,"Rewrite them to 50\u2013160 characters.",e.map(t=>({location:t.url,snippet:`${t.metaDescription.length} chars`})),e.length)},Qt=n=>{let e=R(n,t=>!t.canonical);return e.length===0?null:y("missing-canonical","technical","warning","Pages without a canonical URL",`${e.length} page(s) declare no rel=canonical, risking duplicate-content dilution (e.g. trailing slash, query-string, and www variants competing).`,'Add <link rel="canonical" href="..."> pointing to the preferred URL of each page.',e.map(t=>({location:t.url})),e.length)},Yt=n=>{let e=R(n,t=>!t.viewport);return e.length===0?null:y("missing-viewport","technical","critical","Pages without a viewport meta tag",`${e.length} page(s) lack a viewport tag; Google indexes mobile-first, and pages that render poorly on mobile rank poorly.`,'Add <meta name="viewport" content="width=device-width, initial-scale=1"> to every page.',e.map(t=>({location:t.url})),e.length)},Xt=n=>{let e=R(n,t=>!t.lang);return e.length===0?null:y("missing-lang","technical","info","Pages without an html lang attribute",`${e.length} page(s) don't declare a language, which hurts accessibility and language targeting.`,'Set <html lang="en"> (or the correct language) on every page.',e.map(t=>({location:t.url})),e.length)},Zt=n=>{let e=R(n,t=>/noindex/i.test(t.metaRobots??"")||/noindex/i.test(t.xRobotsTag??""));return e.length===0?null:y("noindex-pages","technical","warning","Crawlable pages marked noindex",`${e.length} page(s) reachable from internal links or the sitemap are marked noindex (via meta robots or the X-Robots-Tag header) and will never rank.`,"Confirm these pages should be excluded; if not, remove the noindex directive. If they should, remove them from the sitemap.",e.map(t=>({location:t.url,snippet:t.metaRobots??`X-Robots-Tag: ${t.xRobotsTag}`})),e.length)},en=n=>{let e=R(n,t=>!!t.headerCanonical&&!!t.canonical&&t.headerCanonical.replace(/\/$/,"")!==t.canonical.replace(/\/$/,""));return e.length===0?null:y("canonical-conflict","technical","warning","HTTP Link header canonical contradicts the HTML canonical",`${e.length} page(s) send a rel=canonical Link header that differs from the <link rel="canonical"> in the HTML. Conflicting signals make search engines pick one arbitrarily.`,"Serve a single canonical signal \u2014 remove one of the two or make them identical.",e.map(t=>({location:t.url,snippet:`header: ${t.headerCanonical} vs html: ${t.canonical}`})),e.length)},tn=n=>n.robots.exists?null:y("robots-missing","technical","warning","No robots.txt","The site has no robots.txt, so crawlers get no crawling guidance and no sitemap pointer.","Add a robots.txt that allows crawling and declares the sitemap URL.",[{location:`${n.origin}/robots.txt`,snippet:"404 / unreachable"}]),nn=n=>n.sitemapExists?null:y("sitemap-missing","technical","warning","No XML sitemap","No sitemap.xml was found at the default location or declared in robots.txt; new and deep pages get discovered more slowly.","Generate a sitemap.xml, keep it updated automatically, and reference it from robots.txt.",[{location:`${n.origin}/sitemap.xml`,snippet:"not found"}]),rn=n=>{let e=R(n,t=>t.fetchTimeMs>2500);return e.length===0?null:y("slow-pages","technical","info","Slow server responses",`${e.length} page(s) took over 2.5s to deliver HTML. Slow responses hurt Core Web Vitals and crawl rate.`,"Investigate server/CDN caching for these routes; aim for HTML delivery under 800ms.",e.map(t=>({location:t.url,snippet:`${t.fetchTimeMs}ms`})),e.length)},on=n=>{let e=R(n,t=>t.h1s.length===0);return e.length===0?null:y("missing-h1","content","critical","Pages without an H1",`${e.length} page(s) have no H1 heading. Both search and answer engines use the H1 to understand what a page is about.`,"Add exactly one descriptive H1 to every page.",e.map(t=>({location:t.url})),e.length)},sn=n=>{let e=R(n,t=>t.h1s.length>1);return e.length===0?null:y("multiple-h1","content","warning","Pages with multiple H1s",`${e.length} page(s) have more than one H1, diluting the page's topical focus.`,"Keep one H1 per page and demote the rest to H2.",e.map(t=>({location:t.url,snippet:t.h1s.slice(0,3).join(" | ")})),e.length)},an=n=>{let e=R(n,t=>{let r=0;for(let o of t.headings){if(r>0&&o.level>r+1)return!0;r=o.level}return!1});return e.length===0?null:y("heading-hierarchy","content","info","Skipped heading levels",`${e.length} page(s) skip heading levels (e.g. H1 \u2192 H3), which weakens the document outline machines rely on.`,"Nest headings sequentially: H1 \u2192 H2 \u2192 H3.",e.map(t=>({location:t.url})),e.length)},cn=n=>{let e=R(n,t=>t.wordCount>0&&t.wordCount<150);return e.length===0?null:y("thin-content","content","warning","Thin content pages",`${e.length} page(s) have under 150 words of visible text. Thin pages rarely rank and give answer engines nothing to cite. (Note: for client-rendered apps the crawler only sees server HTML.)`,"Expand these pages with substantive content, or noindex genuinely utility-only pages. If your site is client-rendered, ensure key content is server-rendered or prerendered.",e.map(t=>({location:t.url,snippet:`${t.wordCount} words`})),e.length)},ln=n=>{let e=I(n).map(r=>({p:r,missing:r.images.filter(o=>o.alt===void 0||o.alt==="")})).filter(r=>r.missing.length>0);if(e.length===0)return null;let t=e.reduce((r,o)=>r+o.missing.length,0);return y("images-missing-alt","content","warning","Images without alt text",`${t} image(s) across ${e.length} page(s) have missing or empty alt attributes, losing image-search traffic and accessibility.`,'Add descriptive alt text to meaningful images; use alt="" only for purely decorative ones.',e.map(r=>({location:r.p.url,snippet:`${r.missing.length} image(s), e.g. ${r.missing[0].src||"(inline)"}`})),t)},dn=n=>{if(!n.sitemapExists||n.sitemapUrls.length===0)return null;let e=new Set;for(let r of n.pages)for(let o of r.internalLinks)e.add(o.replace(/\/$/,""));let t=n.sitemapUrls.filter(r=>{let o=r.replace(/\/$/,"");return!e.has(o)&&o!==n.origin});return t.length===0?null:y("orphan-pages","content","info","Sitemap pages with no internal links",`${t.length} sitemap URL(s) were never linked from any crawled page. Pages without internal links get little authority and are treated as unimportant.`,"Link to these pages from related content or navigation.",t.slice(0,8).map(r=>({location:r})),t.length)};function ce(n){let e=[],t=r=>{if(Array.isArray(r))return r.forEach(t);if(r&&typeof r=="object"){let o=r["@type"];typeof o=="string"&&e.push(o),Array.isArray(o)&&e.push(...o.filter(s=>typeof s=="string"));let i=r["@graph"];i&&t(i)}};return n.jsonLd.forEach(t),e}var pn=n=>{let e=R(n,t=>t.jsonLd.length===0);return e.length===0?null:y("no-structured-data","structured-data","warning","Pages without JSON-LD structured data",`${e.length} page(s) carry no schema.org JSON-LD. Structured data powers rich results and is a primary way answer engines identify entities and facts.`,"Add JSON-LD appropriate to each page type (Organization, WebSite, Article, Product, FAQPage\u2026).",e.map(t=>({location:t.url})),e.length)},un=n=>{let e=R(n,t=>t.jsonLdErrors>0);return e.length===0?null:y("jsonld-parse-errors","structured-data","warning","Invalid JSON-LD blocks",`${e.length} page(s) contain JSON-LD that fails to parse; invalid blocks are silently ignored by search engines.`,"Fix the JSON syntax (validate with Google's Rich Results Test).",e.map(t=>({location:t.url,snippet:`${t.jsonLdErrors} invalid block(s)`})),e.length)},gn=n=>I(n).some(t=>ce(t).some(r=>["Organization","WebSite","Person","LocalBusiness"].includes(r)))?null:y("missing-entity-schema","structured-data","warning","No Organization/WebSite schema anywhere","No crawled page declares who is behind the site (Organization, WebSite, or Person schema). Entity clarity is foundational for both knowledge panels and AI answer attribution.","Add Organization + WebSite JSON-LD (name, url, logo, sameAs social links) to the home page.",[{location:n.origin}]),mn=n=>{let e=R(n,t=>!t.og.title&&!t.og.image);return e.length===0?null:y("missing-open-graph","structured-data","warning","Pages without Open Graph tags",`${e.length} page(s) have no og:title/og:image, so shares on social and chat apps render as bare links.`,"Add og:title, og:description, og:image and og:url to every indexable page.",e.map(t=>({location:t.url})),e.length)},hn=n=>{let e=R(n,t=>Object.keys(t.og).length>0&&!t.twitter.card);return e.length===0?null:y("missing-twitter-card","structured-data","info","Pages without a twitter:card tag",`${e.length} page(s) have Open Graph tags but no twitter:card, so X/Twitter may not render a large preview.`,'Add <meta name="twitter:card" content="summary_large_image">.',e.map(t=>({location:t.url})),e.length)},fn=new Set(["HowTo","SpecialAnnouncement","CourseInfo","EstimatedSalary","LearningVideo","ClaimReview","VehicleListing"]),yn=n=>{let e=I(n).map(t=>({p:t,types:ce(t).filter(r=>fn.has(r))})).filter(t=>t.types.length>0);return e.length===0?null:y("deprecated-schema","structured-data","warning","Deprecated schema types with retired rich results",`${e.length} page(s) use schema types whose Google rich results have been retired (e.g. HowTo since Sept 2023). They no longer produce SERP features and add markup weight.`,"Replace or remove the deprecated types (e.g. LearningVideo \u2192 VideoObject). Keep valid Article/Product/Organization markup.",e.map(t=>({location:t.p.url,snippet:t.types.join(", ")})),e.length)},wn=n=>{let e=R(n,t=>ce(t).includes("FAQPage"));return e.length===0?null:y("faq-schema-retired","structured-data","info","FAQPage rich results are retired (schema still useful for AEO)",`${e.length} page(s) use FAQPage schema. Google retired FAQ rich results (May 2026), so expect no SERP feature \u2014 but the markup still helps AI engines extract Q&A content.`,"Keep the markup for answer engines; just don't expect SERP rich results from it. Use QAPage for genuine community Q&A.",e.map(t=>({location:t.url})),e.length)},bn=n=>{let e=n.robots.blockedAgents.filter(o=>Ht.some(i=>o.toLowerCase()===i.toLowerCase())),t=n.robots.blockedAgents.includes("*");if(e.length===0&&!t)return null;let r=t?["* (all crawlers)"]:e;return y("ai-bots-blocked","aeo","critical","AI crawlers blocked in robots.txt",`robots.txt disallows ${r.join(", ")} from the whole site. Blocked AI crawlers (GPTBot, ClaudeBot, PerplexityBot\u2026) mean your content can never be cited in AI answers.`,"Unless exclusion is deliberate policy, allow AI answer-engine crawlers in robots.txt.",[{location:`${n.origin}/robots.txt`,snippet:r.map(o=>`User-agent: ${o} / Disallow: /`).join("; ")}])},vn=n=>n.llmsTxtExists?null:y("llms-txt-missing","aeo","info","No llms.txt","The emerging llms.txt convention gives AI systems a curated, markdown map of your most important content. Early adopters get disproportionate representation in AI answers.","Publish /llms.txt with a short site description and links to key pages in markdown.",[{location:`${n.origin}/llms.txt`,snippet:"not found"}]),kn=n=>I(n).some(t=>ce(t).some(r=>["FAQPage","HowTo","QAPage","Article","BlogPosting"].includes(r)))?null:y("no-answer-schema","aeo","warning","No FAQ/HowTo/Article schema on any page","No crawled page uses answer-oriented schema (FAQPage, HowTo, QAPage, Article). These formats map directly onto the question\u2192answer shape AI assistants extract.","Mark up FAQ sections with FAQPage schema and articles with Article schema.",[{location:n.origin}]),xn=n=>{let e=I(n);return e.length===0||e.filter(r=>r.headings.some(o=>/\?\s*$/.test(o.text)||/^(what|how|why|when|where|who|which|can|should|is|are|does|do)\b/i.test(o.text))).length>0?null:y("no-question-headings","aeo","info","No question-style headings found","None of the crawled pages use question-phrased headings. Answer engines strongly prefer content whose structure mirrors the user's question.",'Add sections with headings like "How does X work?" followed by a direct 2\u20133 sentence answer.',[{location:n.origin}])},V=n=>R(n,e=>e.wordCount>300),An=n=>{let e=V(n);return e.length===0||e.some(t=>t.hasAuthorSignal)?null:y("no-author-signals","aeo","warning","No author or expertise attribution anywhere","None of the content pages carry author signals (byline, meta author, JSON-LD author). Expert attribution is a measured AI-citation lever (~+30% for attributed quotes) and core to E-E-A-T.","Add visible bylines with credentials to substantive content, plus Person/author JSON-LD.",e.slice(0,5).map(t=>({location:t.url})),e.length)},Sn=n=>{let e=I(n).filter(o=>o.contentDate);if(e.length===0)return V(n).length===0?null:y("no-freshness-signals","aeo","info","No machine-readable freshness signals","No crawled page exposes a published/modified date (article:modified_time or JSON-LD dateModified). AI engines cite recently-updated content ~3x more often; without dates, freshness can't be credited.","Emit dateModified in JSON-LD (or article:modified_time meta) and keep it truthful.",[{location:n.origin}]);let t=Date.now()-183*864e5,r=e.filter(o=>Date.parse(o.contentDate)<t);return r.length===0?null:y("stale-content","aeo","info","Content not updated in over 6 months",`${r.length} page(s) declare a modified date older than 6 months. Studies of AI citations show stale pages progressively lose citation eligibility.`,"Refresh and re-date the most important pages (genuinely update them \u2014 don't just bump the date).",r.slice(0,8).map(o=>({location:o.url,snippet:o.contentDate})),r.length)},Pn=n=>{let e=R(n,t=>!t.hasMainLandmark&&t.wordCount>100);return e.length===0?null:y("no-main-landmark","technical","info","Pages without semantic landmarks",`${e.length} page(s) have no <main>/<article> landmark. Semantic structure helps both accessibility and AI agents isolate the primary content from chrome.`,"Wrap primary content in <main> (and articles in <article>).",e.map(t=>({location:t.url})),e.length)},$n=n=>{let t=R(n,r=>{let o=`${r.title??""} ${r.h1s.join(" ")}`.toLowerCase();return/\bvs\.?\b|\bversus\b|\bbest \b|\btop \d|\bcomparison\b|\balternatives?\b/.test(o)&&r.wordCount>200}).filter(r=>r.tableCount===0);return t.length===0?null:y("comparison-without-table","aeo","info","Comparison-intent pages without comparison tables",`${t.length} page(s) target comparison queries (vs/best/top/alternatives) but contain no table. Tables beat prose for comparison extraction, and comparison content earns ~a third of AI citations.`,"Add a structured comparison table with the entities and attributes being compared.",t.map(r=>({location:r.url,snippet:r.title})),t.length)},Rn=n=>{let e=V(n).filter(r=>r.sectionWordCounts.length>=2);if(e.length===0)return null;let t=e.filter(r=>!r.sectionWordCounts.some(o=>o>=30&&o<=170));return t.length===0?null:y("poor-answer-blocks","aeo","info","No extractable answer blocks",`${t.length} structured page(s) have no section in the ~40\u2013170 word range AI engines prefer to extract (their sections are all very short fragments or very long walls).`,"Lead each H2/H3 section with a self-contained 40\u201360 word direct answer before elaborating.",t.map(r=>({location:r.url,snippet:`sections: ${r.sectionWordCounts.slice(0,8).join(", ")} words`})),t.length)},Cn=/^[a-z]{2,3}(-[a-z]{2}|-\d{3})?$|^x-default$/i,Q=n=>R(n,e=>e.hreflang.length>0),In=n=>{let e=Q(n).map(t=>({p:t,bad:t.hreflang.filter(r=>!Cn.test(r.lang)||/^en-uk$/i.test(r.lang)||r.lang.includes("_"))})).filter(t=>t.bad.length>0);return e.length===0?null:y("hreflang-invalid-codes","technical","warning","Invalid hreflang language codes",`${e.length} page(s) declare hreflang values that aren't valid ISO codes (common mistakes: en-UK instead of en-GB, underscores instead of hyphens). Invalid codes are silently ignored, breaking the whole cluster.`,"Use ISO 639-1 language + optional ISO 3166-1 region, hyphen-separated (en, en-GB, fr-CA) or x-default.",e.slice(0,8).map(t=>({location:t.p.url,snippet:t.bad.map(r=>r.lang).join(", ")})),e.length)},jn=n=>{let e=Q(n).filter(t=>!t.hreflang.some(r=>r.href.replace(/\/$/,"")===t.url.replace(/\/$/,"")));return e.length===0?null:y("hreflang-no-self-reference","technical","warning","hreflang sets without a self-referencing entry",`${e.length} page(s) declare hreflang alternates but omit the required self-referencing entry. Google treats sets without self-reference as invalid.`,"Every page in an hreflang cluster must include itself in its own alternate list.",e.slice(0,8).map(t=>({location:t.url})),e.length)},En=n=>{let e=Q(n).filter(t=>!t.hreflang.some(r=>r.lang.toLowerCase()==="x-default"));return e.length===0?null:y("hreflang-no-x-default","technical","info","hreflang sets without x-default",`${e.length} page(s) declare language alternates without an x-default fallback for unmatched locales.`,'Add <link rel="alternate" hreflang="x-default" href="..."> pointing at the language-selector or primary version.',e.slice(0,8).map(t=>({location:t.url})),e.length)},On=n=>{let e=new Map(I(n).map(r=>[r.url.replace(/\/$/,""),r])),t=[];for(let r of Q(n))for(let o of r.hreflang){let i=e.get(o.href.replace(/\/$/,""));if(!i||i.url===r.url)continue;i.hreflang.some(a=>a.href.replace(/\/$/,"")===r.url.replace(/\/$/,""))||t.push({from:r.url,to:i.url})}return t.length===0?null:y("hreflang-not-reciprocal","technical","warning","hreflang links without return tags",`${t.length} hreflang link(s) between crawled pages are not reciprocated. Without return tags, Google ignores the annotation entirely.`,"Every alternate must link back: if A lists B, B must list A.",t.slice(0,8).map(r=>({location:r.from,snippet:`\u2192 ${r.to} does not link back`})),t.length)},Ln=n=>{let e=Q(n).filter(t=>!!t.canonical&&t.canonical.replace(/\/$/,"")!==t.url.replace(/\/$/,""));return e.length===0?null:y("hreflang-canonical-conflict","technical","warning","hreflang pages canonicalizing to a different URL",`${e.length} page(s) in hreflang clusters set a canonical pointing away from themselves. Cross-URL canonicals inside hreflang sets tell Google to ignore that locale version.`,"Localized pages must self-canonicalize; never canonicalize one locale to another.",e.slice(0,8).map(t=>({location:t.url,snippet:`canonical: ${t.canonical}`})),e.length)},Qe=n=>R(n,e=>{let t=new URL(e.url).pathname.toLowerCase(),r=(e.title??"").toLowerCase();return(/pricing|plans|prices/.test(t)||/pricing|plans\b/.test(r))&&e.wordCount>30}),Ye=/[$€£¥₹]\s?\d|\d+\s?(?:\/|per\s)\s?(?:mo|month|user|seat|yr|year)\b|free\s+(?:plan|tier|forever)/i,Tn=n=>{let e=Qe(n).filter(t=>!Ye.test(t.textSample));return e.length===0?null:y("prices-not-machine-readable","aeo","warning","Pricing pages without machine-readable prices",`${e.length} pricing page(s) contain no readable price in their HTML text. If prices live in images, JS-only widgets, or behind "Contact us", AI agents answering "what does this cost?" will skip you for a competitor whose prices they can read.`,`Put real prices in HTML text (and ideally Offer JSON-LD). Quick self-test: ask an AI assistant "what are the plans and prices on <url>?" \u2014 if it can't answer, agents can't either.`,e.map(t=>({location:t.url,snippet:t.title})),e.length)},Dn=n=>{let t=Qe(n).filter(r=>{let o=JSON.stringify(r.jsonLd);return Ye.test(r.textSample)&&!/"Offer"|"AggregateOffer"|"PriceSpecification"|"priceCurrency"/.test(o)});return t.length===0?null:y("pricing-no-offer-schema","structured-data","info","Pricing pages without Offer schema",`${t.length} pricing page(s) show prices in text but don't declare them in Offer/PriceSpecification JSON-LD, leaving extraction to guesswork.`,"Add Offer JSON-LD per plan (name, price, priceCurrency, billing period).",t.map(r=>({location:r.url})),t.length)},Mn=["in today's digital landscape","in today's fast-paced world","in this day and age","it's important to note that","it is worth mentioning","when it comes to","at the end of the day","unlock the power of","unlock your potential","game-changer","revolutionize the way","seamlessly integrate","elevate your","harness the power","navigate the complexities","a testament to","ever-evolving landscape","cutting-edge technology","take your business to the next level","look no further","delve into","embark on a journey"],_n=n=>{let e=V(n).map(t=>{let r=t.textSample.toLowerCase(),o=Mn.filter(i=>r.includes(i));return{p:t,hits:o}}).filter(t=>t.hits.length>=2);return e.length===0?null:y("filler-content","content","info","Generic filler phrasing detected",`${e.length} page(s) lean on stock marketing/AI filler phrases. Generic phrasing depresses reader trust and gives AI engines nothing distinctive to cite.`,"Replace filler with specific claims: concrete numbers, named examples, and plain statements of what the thing does.",e.slice(0,8).map(t=>({location:t.p.url,snippet:t.hits.slice(0,3).map(r=>`"${r}"`).join(", ")})),e.length)},Nn=n=>{let e=V(n).filter(t=>t.wordCount>500&&!/\d/.test(t.textSample));return e.length===0?null:y("no-concrete-data","content","info","Long-form pages without a single number",`${e.length} page(s) over 500 words contain no numeric data at all. Specific statistics (with sources) are among the strongest measured AI-citation levers; prose without data reads as opinion.`,"Add concrete data points \u2014 metrics, dates, prices, benchmark results \u2014 with their sources.",e.slice(0,8).map(t=>({location:t.url})),e.length)},O=n=>n.replace(/\/$/,"");function Xe(n){let e=new Set(I(n).map(r=>O(r.url))),t=new Map;for(let r of I(n)){let o=new Set;for(let i of r.internalLinkDetails){let s=O(i.href);!e.has(s)||s===O(r.url)||o.has(s)||(o.add(s),t.set(s,[...t.get(s)??[],{from:r.url,anchor:i.anchor}]))}}return{incoming:t,crawledSet:e}}var ve=10,Fn=n=>{if(I(n).length<ve)return null;let{incoming:e}=Xe(n),t=O(n.origin),r=I(n).filter(o=>O(o.url)!==t&&(e.get(O(o.url))?.length??0)<3);return r.length===0?null:y("weak-incoming-links","content","info","Pages with fewer than 3 incoming internal links",`${r.length} crawled page(s) receive fewer than 3 internal links from other crawled pages. Pages with little internal linking accumulate less authority and are treated as unimportant. (Counts reflect the crawled sample, not the full site.)`,"Link to these pages from related content and hub pages \u2014 every substantive page should have at least 3 incoming internal links.",r.slice(0,8).map(o=>({location:o.url,snippet:`${e.get(O(o.url))?.length??0} incoming`})),r.length)},Gn=n=>{if(I(n).length<ve)return null;let{incoming:e}=Xe(n),t=[];for(let[r,o]of e){if(o.length<5)continue;let i=new Map;for(let s of o){let a=s.anchor.toLowerCase().trim();a&&i.set(a,(i.get(a)??0)+1)}for(let[s,a]of i)if(a/o.length>.4&&s.length>2){t.push({url:r,anchor:s,share:Math.round(a/o.length*100)});break}}return t.length===0?null:y("anchor-over-optimization","content","info","Repetitive anchor text to the same pages",`${t.length} page(s) receive over 40% of their internal links with the identical anchor text. Anchor diversity reads as natural; repetition reads as over-optimization.`,"Vary the anchors linking to these pages (mix branded, descriptive, and partial-match anchors).",t.slice(0,8).map(r=>({location:r.url,snippet:`"${r.anchor}" = ${r.share}% of incoming anchors`})),t.length)},qn=n=>{let e=I(n);if(e.length<ve)return null;let t=O(n.origin),r=new Map;for(let a of e)r.set(O(a.url),a.internalLinkDetails.map(d=>O(d.href)));let o=new Map([[t,0]]),i=[t];for(;i.length>0;){let a=i.shift();for(let d of r.get(a)??[])!o.has(d)&&r.has(d)&&(o.set(d,o.get(a)+1),i.push(d))}let s=e.filter(a=>(o.get(O(a.url))??99)>2);return s.length===0?null:y("deep-pages","content","info","Pages more than 2 clicks from the homepage",`${s.length} crawled page(s) are not reachable within 2 clicks of the homepage (via crawled links). Depth dilutes crawl priority and authority flow.`,"Surface these pages through hub pages, navigation, or contextual links so everything important is within 2 clicks.",s.slice(0,8).map(a=>({location:a.url,snippet:`depth ${o.get(O(a.url))??"unreachable"}`})),s.length)},Jn=n=>{let e=I(n);if(e.length===0)return null;let t=e.filter(r=>r.wordCount<40&&r.title);return t.length<Math.max(2,e.length*.5)?null:y("client-rendered-content","aeo","critical","Content appears to be client-side rendered",`${t.length} of ${e.length} pages deliver almost no text in their HTML. Many AI crawlers do not execute JavaScript, so they see empty pages.`,"Server-render or prerender your pages (SSR/SSG) so full content is present in the initial HTML.",t.slice(0,8).map(r=>({location:r.url,snippet:`${r.wordCount} words in HTML`})),t.length)},Hn=[Ut,Bt,Kt,zt,Wt,Vt,Qt,Yt,Xt,Zt,en,tn,nn,rn,on,sn,an,cn,ln,dn,pn,un,gn,mn,hn,yn,wn,bn,vn,kn,xn,An,Sn,Pn,$n,Rn,In,jn,En,On,Ln,Tn,Dn,_n,Nn,Fn,Gn,qn,Jn];function Ze(n){let e=[];for(let t of Hn){let r=t(n);r&&e.push(r)}return e}var et={technical:"Technical SEO",content:"On-Page Content","structured-data":"Structured Data",aeo:"AEO Readiness",performance:"Performance"},tt={technical:.25,content:.25,"structured-data":.15,aeo:.2,performance:.15},Un={critical:25,warning:9,info:3,pass:0};function Bn(n){let e=Un[n.severity]??0,t=Math.min(1.8,1+Math.log10(Math.max(1,n.occurrences))*.5),r=n.disputed?.4:1;return e*t*r}function le(n,e={}){let t=s=>s!=="performance"||e.performanceMeasured||n.some(a=>a.category==="performance"),r=Object.keys(et).filter(t).map(s=>{let a=n.filter(d=>d.category===s).reduce((d,m)=>d+Bn(m),0);return{category:s,label:et[s],score:Math.max(0,Math.round(100-a))}}),o=r.reduce((s,a)=>s+tt[a.category],0);return{overall:Math.round(r.reduce((s,a)=>s+a.score*tt[a.category],0)/o),categories:r}}async function nt(n,e,t=6e4){let r=new URL("https://www.googleapis.com/pagespeedonline/v5/runPagespeed");r.searchParams.set("url",n),r.searchParams.set("strategy","mobile"),r.searchParams.set("category","performance"),e&&r.searchParams.set("key",e);let o=await fetch(r,{signal:AbortSignal.timeout(t)});if(o.status===429)throw new Error(e?"PageSpeed API quota exceeded for your key":"PageSpeed keyless quota exhausted \u2014 add a free Google API key in Settings to enable performance measurement");if(!o.ok){let u=await o.text().catch(()=>"");throw new Error(`PageSpeed API ${o.status}: ${u.slice(0,160)}`)}let i=await o.json(),s=i.loadingExperience?.metrics??{},a=s.LARGEST_CONTENTFUL_PAINT_MS?.percentile,d=s.INTERACTION_TO_NEXT_PAINT?.percentile,m=s.CUMULATIVE_LAYOUT_SHIFT_SCORE?.percentile,w=i.lighthouseResult?.categories?.performance?.score;return{url:n,lighthouseScore:w!==void 0?Math.round(w*100):void 0,lcpMs:a,inpMs:d,cls:m!==void 0?m/100:void 0,fieldDataAvailable:Object.keys(s).length>0}}var Kn=0;function de(n,e,t,r,o,i,s){return{id:`pf-${++Kn}-${n}`,ruleId:n,category:"performance",severity:e,title:t,description:r,recommendation:o,evidence:[{location:i,snippet:s}],occurrences:1,source:"rule"}}function rt(n){let e=[];return n.lighthouseScore!==void 0&&n.lighthouseScore<80&&e.push(de("perf-lighthouse",n.lighthouseScore<50?"critical":"warning",`Lighthouse performance score ${n.lighthouseScore}/100`,"Lab performance is below Google's 'good' band. Slow pages lose rankings, conversions, and crawl budget.","Run PageSpeed Insights for the detailed opportunity list (render-blocking resources, image sizing, JS payload) and fix the top items.",n.url,`Lighthouse ${n.lighthouseScore}/100 (mobile)`)),n.lcpMs!==void 0&&n.lcpMs>2500&&e.push(de("perf-lcp",n.lcpMs>4e3?"critical":"warning","Largest Contentful Paint is too slow (field data)",`Real users experience LCP of ${(n.lcpMs/1e3).toFixed(1)}s; Google's 'good' threshold is 2.5s. LCP is a confirmed ranking signal.`,"Optimize the hero image/text delivery: preload the LCP resource, use a CDN, compress images, and cut render-blocking CSS/JS.",n.url,`LCP p75 = ${(n.lcpMs/1e3).toFixed(1)}s`)),n.inpMs!==void 0&&n.inpMs>200&&e.push(de("perf-inp",n.inpMs>500?"critical":"warning","Interaction to Next Paint is too slow (field data)",`Real-user INP is ${n.inpMs}ms; 'good' is under 200ms. Sluggish interactions hurt the page experience signal.`,"Break up long JavaScript tasks, defer non-critical scripts, and minimize main-thread work on interaction handlers.",n.url,`INP p75 = ${n.inpMs}ms`)),n.cls!==void 0&&n.cls>.1&&e.push(de("perf-cls",n.cls>.25?"critical":"warning","Cumulative Layout Shift is too high (field data)",`Real-user CLS is ${n.cls.toFixed(2)}; 'good' is under 0.10. Shifting layouts frustrate users and hurt rankings.`,"Set explicit width/height on images and embeds, reserve space for ads/banners, and avoid injecting content above existing content.",n.url,`CLS p75 = ${n.cls.toFixed(2)}`)),e}async function Y(n,e,t){let r=Le(e),o=t.onProgress??(()=>{});async function i(u,g,l){let c=Date.now();o(u,g);let p=setInterval(()=>{let h=Math.round((Date.now()-c)/1e3);o(`${u} \u2014 ${h}s elapsed, still working`,g)},5e3);p.unref?.();try{return await l()}finally{clearInterval(p)}}o(`Crawling ${r}${t.render?" (JS rendering)":""}`,.05);let s=await Te(r,{maxPages:t.maxPages,timeoutMs:t.timeoutMs,render:t.render,onProgress:(u,g,l)=>o(`Crawled ${u}/${g}: ${l}`,.05+.45*(u/g))});if(s.pages.length===0)throw new Error(`Could not fetch any page from ${r} \u2014 is the domain reachable?`);o(`Round 1: rule engine over ${s.pages.length} pages`,.5);let a=Ze(s),d=!1;if(t.measurePerformance)try{let u=`psi:${s.origin}`,g=t.cache?.kvGet(u,864e5)??null;g||(g=await i("Measuring Core Web Vitals (PageSpeed Insights)",.55,()=>nt(s.origin,t.psiApiKey)),t.cache?.kvSet(u,g)),a=[...a,...rt(g)],d=g.lighthouseScore!==void 0||g.fieldDataAvailable,o(`Performance: Lighthouse ${g.lighthouseScore??"n/a"}${g.fieldDataAvailable?", CrUX field data available":""}`,.6)}catch(u){o(`PageSpeed measurement failed (${u instanceof Error?u.message:u}); skipping performance category`,.6)}if(n&&!t.skipLlm){try{let u=await i(`Round 2: AI auditor reviewing ${s.pages.length} crawled pages`,.65,()=>De(n,s,a));a=[...a,...u],o(`AI auditor added ${u.length} finding(s)`,.74)}catch(u){o(`AI audit round failed (${u instanceof Error?u.message:u}); continuing with rule findings`,.74)}try{a=await i("Dissenter lens reviewing rule findings for false positives",.76,()=>Me(n,s,a));let u=a.filter(g=>g.disputed).length;u>0&&o(`Dissenter disputed ${u} rule finding(s) as inapplicable here`,.78)}catch{}try{let u=await i("Running the AI paste test on the pricing page",.8,()=>_e(n,s));u.length>0&&(a=[...a,...u],o("Pricing page failed the AI paste test",.8))}catch{}}let m=le(a,{performanceMeasured:d}),w=Re.parse({id:zn(),type:"domain",domain:r,createdAt:new Date().toISOString(),pagesCrawled:s.pages.length,pages:s.pages.map(u=>({url:u.url,status:u.status,title:u.title,metaDescription:u.metaDescription,h1:u.h1s[0],wordCount:u.wordCount,canonical:u.canonical,metaRobots:u.metaRobots,jsonLdCount:u.jsonLd.length,hasOg:Object.keys(u.og).length>0})),scorecard:m,findings:a});if(n&&!t.skipLlm)try{w.llmReport=await i("Writing the narrative report and action plan",.85,()=>Z(n,{subject:r,scorecard:m,findings:a,contentSamples:s.pages.slice(0,5).map(u=>({location:u.url,sample:u.textSample}))}))}catch(u){o(`AI report failed (${u instanceof Error?u.message:u}); returning rule-based report`,.95)}return o("Done",1),w}import{randomUUID as Wn}from"crypto";var Vn={type:"object",additionalProperties:!1,required:["keywords","gaps","summary"],properties:{summary:{type:"string",description:"3-4 sentence overview of keyword coverage and the biggest opportunity"},keywords:{type:"array",items:{type:"object",additionalProperties:!1,required:["keyword","intent","coverage","aeoCitable","notes"],properties:{keyword:{type:"string"},intent:{type:"string",enum:["informational","navigational","transactional","commercial"]},coverage:{type:"string",enum:["strong","partial","missing"],description:"How well the crawled site currently covers this keyword"},bestPage:{type:"string",description:"URL of the best existing page for this keyword, omit if none"},aeoCitable:{type:"boolean",description:"Would an AI answer engine plausibly cite this site for this query today?"},notes:{type:"string",description:"Specific advice for this keyword on this site"},stage:{type:"string",enum:["awareness","consideration","decision","implementation"],description:"Buyer-journey stage: awareness (what is/how to/guide), consideration (best/top/vs/alternatives), decision (pricing/reviews/demo/trial), implementation (templates/examples/tutorial/setup)"},fanOut:{type:"array",maxItems:5,description:"AI engines fan a query out into related sub-queries. List the 3-5 sub-queries this keyword triggers, and whether the crawled site has content covering each.",items:{type:"object",additionalProperties:!1,required:["query","covered"],properties:{query:{type:"string"},covered:{type:"boolean",description:"Does any crawled page substantively cover this sub-query?"}}}}}}},gaps:{type:"array",description:"High-value related keywords the site does NOT target but should",items:{type:"object",additionalProperties:!1,required:["keyword","rationale"],properties:{keyword:{type:"string"},rationale:{type:"string"}}}}}};async function ot(n,e,t,r={}){let o="You are a senior SEO/AEO keyword strategist. You are given a crawled snapshot of a website (page titles, descriptions, headings, word counts) and a list of target keywords. Analyze how well the site covers each keyword for both classic search and AI answer engines. Base every judgment strictly on the provided pages; if the site has no relevant page, say coverage is missing.",i=e.pages.map(w=>({url:w.url,title:w.title,description:w.metaDescription,h1:w.h1,words:w.wordCount})),s=r.gscQueries&&r.gscQueries.length>0?`REAL Google Search Console data for this site (last 90 days, top queries):
26
- ${r.gscQueries.slice(0,60).map(w=>`- "${w.query}": ${w.impressions} impressions, ${w.clicks} clicks, avg position ${w.position.toFixed(1)}`).join(`
26
+ `)),process.exit(1))}import{spawn as st}from"child_process";import*as k from"@clack/prompts";import V from"picocolors";var an=[{value:void 0,label:"Default (recommended)",hint:"whatever your Claude login defaults to"},{value:"claude-sonnet-5",label:"Sonnet 5",hint:"best balance for audits"},{value:"claude-opus-5",label:"Opus 5",hint:"deepest analysis, slower"},{value:"claude-haiku-4-5",label:"Haiku 4.5",hint:"fastest, lighter analysis"}],cn=[{value:void 0,label:"Default (recommended)",hint:"Codex default model"},{value:"gpt-5-codex",label:"GPT-5 Codex",hint:"agentic coding model"}],ln=[{value:void 0,label:"Default (recommended)",hint:"Gemini CLI default model"},{value:"gemini-2.5-pro",label:"Gemini 2.5 Pro",hint:"deepest analysis"},{value:"gemini-2.5-flash",label:"Gemini 2.5 Flash",hint:"fastest"}];function F(n="Setup cancelled."){k.cancel(n),process.exit(1)}async function dn(){let n=Se();return n?(k.log.step("Opening ChatGPT sign-in in your browser\u2026"),await new Promise(t=>{let r=st(n,["login"],{stdio:"inherit"});r.on("close",o=>t(o??1)),r.on("error",()=>t(1))})===0):(k.log.error("No bundled codex binary for this platform. Install the Codex CLI and run `codex login`, or use an API key."),!1)}async function pn(){let n=le();if(!n)return k.log.error("Bundled Gemini CLI not found \u2014 reinstall the package, or use an API key."),!1;k.log.step("Launching Gemini CLI for Google sign-in. Complete the browser login, then exit Gemini (type /quit or press Ctrl+C).");let e=await new Promise(t=>{let r=st(process.execPath,[n],{stdio:"inherit"});r.on("close",o=>t(o??1)),r.on("error",()=>t(1))});return e===0||e===130}async function $e(n){k.intro(V.bgCyan(V.black(" Pulse Media SEO/AEO Audit ")));let e=await k.select({message:"Which AI provider do you want to use?",options:[{value:"claude",label:"Claude (Anthropic)",hint:"reuses your Claude Code subscription login"},{value:"openai",label:"OpenAI",hint:"sign in with ChatGPT or use an API key"},{value:"gemini",label:"Gemini (Google)",hint:"sign in with Google \u2014 free tier available"}],initialValue:n?.provider??"claude"});k.isCancel(e)&&F();let t={provider:e,crawl:n?.crawl??{maxPages:25,timeoutMs:1e4,render:!1},performance:n?.performance??!0,economy:n?.economy??!0,lightModel:n?.lightModel,psiApiKey:n?.psiApiKey,dataForSeoLogin:n?.dataForSeoLogin,dataForSeoPassword:n?.dataForSeoPassword,gsc:n?.gsc,anthropicApiKey:n?.anthropicApiKey,openaiApiKey:n?.openaiApiKey,geminiApiKey:n?.geminiApiKey,createdAt:n?.createdAt};if(t.provider==="claude"){let a=C(t).authStatus();if(a.method!=="unknown")k.log.success(`Auth detected: ${a.detail}`),t.authMethod=a.method;else{let d=await k.select({message:"How do you want to authenticate with Claude?",options:[{value:"subscription",label:"Claude subscription (recommended)",hint:"requires Claude Code: run `claude` once to log in"},{value:"api-key",label:"Anthropic API key"}]});if(k.isCancel(d)&&F(),t.authMethod=d,d==="api-key"){let m=await k.password({message:"Paste your Anthropic API key (stored in ~/.pulsemedia/config.json, chmod 600):"});k.isCancel(m)&&F(),t.anthropicApiKey=m.trim()}else k.log.info("If verification fails below, run `claude` in another terminal to log in, then re-run this setup.")}}else if(t.provider==="openai"){let a=C(t).authStatus();if(a.method!=="unknown")k.log.success(`Auth detected: ${a.detail}`),t.authMethod=a.method;else{let d=await k.select({message:"How do you want to authenticate with OpenAI?",options:[{value:"subscription",label:"Sign in with ChatGPT (recommended)",hint:"opens your browser"},{value:"api-key",label:"OpenAI API key"}]});if(k.isCancel(d)&&F(),t.authMethod=d,d==="api-key"){let m=await k.password({message:"Paste your OpenAI API key (stored in ~/.pulsemedia/config.json, chmod 600):"});k.isCancel(m)&&F(),t.openaiApiKey=m.trim()}else await dn()||k.log.warn("ChatGPT sign-in did not complete \u2014 verification below will tell us where we stand.")}}else{let a=C(t).authStatus();if(a.method!=="unknown")k.log.success(`Auth detected: ${a.detail}`),t.authMethod=a.method;else{let d=await k.select({message:"How do you want to authenticate with Gemini?",options:[{value:"subscription",label:"Sign in with Google (recommended)",hint:"free tier / Google AI Pro \u2014 opens Gemini CLI once"},{value:"api-key",label:"Gemini API key",hint:"from aistudio.google.com"}]});if(k.isCancel(d)&&F(),t.authMethod=d,d==="api-key"){let m=await k.password({message:"Paste your Gemini API key (stored in ~/.pulsemedia/config.json, chmod 600):"});k.isCancel(m)&&F(),t.geminiApiKey=m.trim()}else await pn()||k.log.warn("Google sign-in did not complete \u2014 verification below will tell us where we stand.")}}let r=t.provider==="claude"?an:t.provider==="openai"?cn:ln,o=await k.select({message:"Which model should power the analysis?",options:[...r.map(a=>({value:a.value??"__default",label:a.label,hint:a.hint})),{value:"__custom",label:"Other (type a model ID)"}],initialValue:n?.model??"__default"});if(k.isCancel(o)&&F(),o==="__custom"){let a=await k.text({message:"Model ID:"});k.isCancel(a)&&F(),t.model=a.trim()}else o!=="__default"&&(t.model=o);let i=k.spinner();i.start(`Verifying ${t.provider==="claude"?"Claude":"OpenAI"} connection`);let s=await C(t).verify();if(s.ok)i.stop(V.green(`\u2713 ${s.detail}`));else{i.stop(V.red(`\u2717 ${s.detail}`));let a=await k.confirm({message:"Verification failed. Save this configuration anyway?",initialValue:!1});(k.isCancel(a)||!a)&&F("Setup aborted \u2014 nothing saved.")}return B(t),k.outro(`Configuration saved. ${V.dim("Change it anytime with `pulse-seo login` or in the web UI settings.")}`),t}async function Q(){return M()??$e()}import ne from"fs";import q from"path";import{fileURLToPath as so}from"url";import{Hono as ao}from"hono";import{streamSSE as co}from"hono/streaming";import{serve as Dt}from"@hono/node-server";import{z as A}from"zod";import{randomUUID as fr}from"crypto";var un=0;function w(n,e,t,r,o,i,s,a=s.length||1){return{id:`f-${++un}-${n}`,ruleId:n,category:e,severity:t,title:r,description:o,recommendation:i,evidence:s.slice(0,8),occurrences:a,source:"rule"}}var I=n=>n.pages.filter(e=>e.status>=200&&e.status<300);function R(n,e){return I(n).filter(e)}var gn=["GPTBot","ClaudeBot","Claude-Web","PerplexityBot","Google-Extended","OAI-SearchBot","anthropic-ai"],mn=n=>{let e=n.pages.filter(t=>t.status>=400);return e.length===0?null:w("http-errors","technical","critical","Pages returning HTTP errors",`${e.length} crawled page(s) returned a 4xx/5xx status. Broken pages waste crawl budget and lose rankings.`,"Fix or redirect the broken URLs, and remove them from the sitemap and internal links.",e.map(t=>({location:t.url,snippet:`HTTP ${t.status}`})),e.length)},hn=n=>{let e=R(n,t=>!t.title);return e.length===0?null:w("missing-title","technical","critical","Pages without a <title> tag",`${e.length} page(s) have no title tag. The title is the single strongest on-page ranking signal and becomes the clickable headline in search results.`,"Add a unique, descriptive <title> (15\u201360 characters) to every page.",e.map(t=>({location:t.url})),e.length)},fn=n=>{let e=R(n,t=>!!t.title&&(t.title.length<15||t.title.length>60));return e.length===0?null:w("title-length","technical","warning","Titles too short or too long",`${e.length} page(s) have titles outside the 15\u201360 character range; long ones get truncated in results, short ones waste the slot.`,"Rewrite titles to 15\u201360 characters, front-loading the primary topic.",e.map(t=>({location:t.url,snippet:`"${t.title}" (${t.title.length} chars)`})),e.length)},yn=n=>{let e=new Map;for(let r of R(n,o=>!!o.title)){let o=r.title.toLowerCase();e.set(o,[...e.get(o)??[],r])}let t=[...e.values()].filter(r=>r.length>1);return t.length===0?null:w("duplicate-titles","technical","warning","Duplicate page titles",`${t.length} title(s) are shared by multiple pages, making it hard for search engines to pick the right page for a query.`,"Give every page a unique title describing its specific content.",t.flatMap(r=>r.map(o=>({location:o.url,snippet:o.title}))),t.reduce((r,o)=>r+o.length,0))},wn=n=>{let e=R(n,t=>!t.metaDescription);return e.length===0?null:w("missing-meta-description","technical","warning","Pages without a meta description",`${e.length} page(s) have no meta description; search engines will improvise a snippet, and answer engines lose a concise summary of the page.`,"Add a 50\u2013160 character meta description that summarizes the page and includes its primary topic.",e.map(t=>({location:t.url})),e.length)},bn=n=>{let e=R(n,t=>!!t.metaDescription&&(t.metaDescription.length<50||t.metaDescription.length>160));return e.length===0?null:w("meta-description-length","technical","info","Meta descriptions outside 50\u2013160 characters",`${e.length} page(s) have meta descriptions that are too short to be useful or long enough to be truncated.`,"Rewrite them to 50\u2013160 characters.",e.map(t=>({location:t.url,snippet:`${t.metaDescription.length} chars`})),e.length)},vn=n=>{let e=R(n,t=>!t.canonical);return e.length===0?null:w("missing-canonical","technical","warning","Pages without a canonical URL",`${e.length} page(s) declare no rel=canonical, risking duplicate-content dilution (e.g. trailing slash, query-string, and www variants competing).`,'Add <link rel="canonical" href="..."> pointing to the preferred URL of each page.',e.map(t=>({location:t.url})),e.length)},kn=n=>{let e=R(n,t=>!t.viewport);return e.length===0?null:w("missing-viewport","technical","critical","Pages without a viewport meta tag",`${e.length} page(s) lack a viewport tag; Google indexes mobile-first, and pages that render poorly on mobile rank poorly.`,'Add <meta name="viewport" content="width=device-width, initial-scale=1"> to every page.',e.map(t=>({location:t.url})),e.length)},An=n=>{let e=R(n,t=>!t.lang);return e.length===0?null:w("missing-lang","technical","info","Pages without an html lang attribute",`${e.length} page(s) don't declare a language, which hurts accessibility and language targeting.`,'Set <html lang="en"> (or the correct language) on every page.',e.map(t=>({location:t.url})),e.length)},Sn=n=>{let e=R(n,t=>/noindex/i.test(t.metaRobots??"")||/noindex/i.test(t.xRobotsTag??""));return e.length===0?null:w("noindex-pages","technical","warning","Crawlable pages marked noindex",`${e.length} page(s) reachable from internal links or the sitemap are marked noindex (via meta robots or the X-Robots-Tag header) and will never rank.`,"Confirm these pages should be excluded; if not, remove the noindex directive. If they should, remove them from the sitemap.",e.map(t=>({location:t.url,snippet:t.metaRobots??`X-Robots-Tag: ${t.xRobotsTag}`})),e.length)},xn=n=>{let e=R(n,t=>!!t.headerCanonical&&!!t.canonical&&t.headerCanonical.replace(/\/$/,"")!==t.canonical.replace(/\/$/,""));return e.length===0?null:w("canonical-conflict","technical","warning","HTTP Link header canonical contradicts the HTML canonical",`${e.length} page(s) send a rel=canonical Link header that differs from the <link rel="canonical"> in the HTML. Conflicting signals make search engines pick one arbitrarily.`,"Serve a single canonical signal \u2014 remove one of the two or make them identical.",e.map(t=>({location:t.url,snippet:`header: ${t.headerCanonical} vs html: ${t.canonical}`})),e.length)},Pn=n=>n.robots.exists?null:w("robots-missing","technical","warning","No robots.txt","The site has no robots.txt, so crawlers get no crawling guidance and no sitemap pointer.","Add a robots.txt that allows crawling and declares the sitemap URL.",[{location:`${n.origin}/robots.txt`,snippet:"404 / unreachable"}]),$n=n=>n.sitemapExists?null:w("sitemap-missing","technical","warning","No XML sitemap","No sitemap.xml was found at the default location or declared in robots.txt; new and deep pages get discovered more slowly.","Generate a sitemap.xml, keep it updated automatically, and reference it from robots.txt.",[{location:`${n.origin}/sitemap.xml`,snippet:"not found"}]),Rn=n=>{let e=R(n,t=>t.fetchTimeMs>2500);return e.length===0?null:w("slow-pages","technical","info","Slow server responses",`${e.length} page(s) took over 2.5s to deliver HTML. Slow responses hurt Core Web Vitals and crawl rate.`,"Investigate server/CDN caching for these routes; aim for HTML delivery under 800ms.",e.map(t=>({location:t.url,snippet:`${t.fetchTimeMs}ms`})),e.length)},Cn=n=>{let e=R(n,t=>t.h1s.length===0);return e.length===0?null:w("missing-h1","content","critical","Pages without an H1",`${e.length} page(s) have no H1 heading. Both search and answer engines use the H1 to understand what a page is about.`,"Add exactly one descriptive H1 to every page.",e.map(t=>({location:t.url})),e.length)},In=n=>{let e=R(n,t=>t.h1s.length>1);return e.length===0?null:w("multiple-h1","content","warning","Pages with multiple H1s",`${e.length} page(s) have more than one H1, diluting the page's topical focus.`,"Keep one H1 per page and demote the rest to H2.",e.map(t=>({location:t.url,snippet:t.h1s.slice(0,3).join(" | ")})),e.length)},En=n=>{let e=R(n,t=>{let r=0;for(let o of t.headings){if(r>0&&o.level>r+1)return!0;r=o.level}return!1});return e.length===0?null:w("heading-hierarchy","content","info","Skipped heading levels",`${e.length} page(s) skip heading levels (e.g. H1 \u2192 H3), which weakens the document outline machines rely on.`,"Nest headings sequentially: H1 \u2192 H2 \u2192 H3.",e.map(t=>({location:t.url})),e.length)},jn=n=>{let e=R(n,t=>t.wordCount>0&&t.wordCount<150);return e.length===0?null:w("thin-content","content","warning","Thin content pages",`${e.length} page(s) have under 150 words of visible text. Thin pages rarely rank and give answer engines nothing to cite. (Note: for client-rendered apps the crawler only sees server HTML.)`,"Expand these pages with substantive content, or noindex genuinely utility-only pages. If your site is client-rendered, ensure key content is server-rendered or prerendered.",e.map(t=>({location:t.url,snippet:`${t.wordCount} words`})),e.length)},On=n=>{let e=I(n).map(r=>({p:r,missing:r.images.filter(o=>o.alt===void 0||o.alt==="")})).filter(r=>r.missing.length>0);if(e.length===0)return null;let t=e.reduce((r,o)=>r+o.missing.length,0);return w("images-missing-alt","content","warning","Images without alt text",`${t} image(s) across ${e.length} page(s) have missing or empty alt attributes, losing image-search traffic and accessibility.`,'Add descriptive alt text to meaningful images; use alt="" only for purely decorative ones.',e.map(r=>({location:r.p.url,snippet:`${r.missing.length} image(s), e.g. ${r.missing[0].src||"(inline)"}`})),t)},Ln=n=>{if(!n.sitemapExists||n.sitemapUrls.length===0)return null;let e=new Set;for(let r of n.pages)for(let o of r.internalLinks)e.add(o.replace(/\/$/,""));let t=n.sitemapUrls.filter(r=>{let o=r.replace(/\/$/,"");return!e.has(o)&&o!==n.origin});return t.length===0?null:w("orphan-pages","content","info","Sitemap pages with no internal links",`${t.length} sitemap URL(s) were never linked from any crawled page. Pages without internal links get little authority and are treated as unimportant.`,"Link to these pages from related content or navigation.",t.slice(0,8).map(r=>({location:r})),t.length)};function pe(n){let e=[],t=r=>{if(Array.isArray(r))return r.forEach(t);if(r&&typeof r=="object"){let o=r["@type"];typeof o=="string"&&e.push(o),Array.isArray(o)&&e.push(...o.filter(s=>typeof s=="string"));let i=r["@graph"];i&&t(i)}};return n.jsonLd.forEach(t),e}var Tn=n=>{let e=R(n,t=>t.jsonLd.length===0);return e.length===0?null:w("no-structured-data","structured-data","warning","Pages without JSON-LD structured data",`${e.length} page(s) carry no schema.org JSON-LD. Structured data powers rich results and is a primary way answer engines identify entities and facts.`,"Add JSON-LD appropriate to each page type (Organization, WebSite, Article, Product, FAQPage\u2026).",e.map(t=>({location:t.url})),e.length)},_n=n=>{let e=R(n,t=>t.jsonLdErrors>0);return e.length===0?null:w("jsonld-parse-errors","structured-data","warning","Invalid JSON-LD blocks",`${e.length} page(s) contain JSON-LD that fails to parse; invalid blocks are silently ignored by search engines.`,"Fix the JSON syntax (validate with Google's Rich Results Test).",e.map(t=>({location:t.url,snippet:`${t.jsonLdErrors} invalid block(s)`})),e.length)},Mn=n=>I(n).some(t=>pe(t).some(r=>["Organization","WebSite","Person","LocalBusiness"].includes(r)))?null:w("missing-entity-schema","structured-data","warning","No Organization/WebSite schema anywhere","No crawled page declares who is behind the site (Organization, WebSite, or Person schema). Entity clarity is foundational for both knowledge panels and AI answer attribution.","Add Organization + WebSite JSON-LD (name, url, logo, sameAs social links) to the home page.",[{location:n.origin}]),Nn=n=>{let e=R(n,t=>!t.og.title&&!t.og.image);return e.length===0?null:w("missing-open-graph","structured-data","warning","Pages without Open Graph tags",`${e.length} page(s) have no og:title/og:image, so shares on social and chat apps render as bare links.`,"Add og:title, og:description, og:image and og:url to every indexable page.",e.map(t=>({location:t.url})),e.length)},Dn=n=>{let e=R(n,t=>Object.keys(t.og).length>0&&!t.twitter.card);return e.length===0?null:w("missing-twitter-card","structured-data","info","Pages without a twitter:card tag",`${e.length} page(s) have Open Graph tags but no twitter:card, so X/Twitter may not render a large preview.`,'Add <meta name="twitter:card" content="summary_large_image">.',e.map(t=>({location:t.url})),e.length)},Fn=new Set(["HowTo","SpecialAnnouncement","CourseInfo","EstimatedSalary","LearningVideo","ClaimReview","VehicleListing"]),Gn=n=>{let e=I(n).map(t=>({p:t,types:pe(t).filter(r=>Fn.has(r))})).filter(t=>t.types.length>0);return e.length===0?null:w("deprecated-schema","structured-data","warning","Deprecated schema types with retired rich results",`${e.length} page(s) use schema types whose Google rich results have been retired (e.g. HowTo since Sept 2023). They no longer produce SERP features and add markup weight.`,"Replace or remove the deprecated types (e.g. LearningVideo \u2192 VideoObject). Keep valid Article/Product/Organization markup.",e.map(t=>({location:t.p.url,snippet:t.types.join(", ")})),e.length)},qn=n=>{let e=R(n,t=>pe(t).includes("FAQPage"));return e.length===0?null:w("faq-schema-retired","structured-data","info","FAQPage rich results are retired (schema still useful for AEO)",`${e.length} page(s) use FAQPage schema. Google retired FAQ rich results (May 2026), so expect no SERP feature \u2014 but the markup still helps AI engines extract Q&A content.`,"Keep the markup for answer engines; just don't expect SERP rich results from it. Use QAPage for genuine community Q&A.",e.map(t=>({location:t.url})),e.length)},Jn=n=>{let e=n.robots.blockedAgents.filter(o=>gn.some(i=>o.toLowerCase()===i.toLowerCase())),t=n.robots.blockedAgents.includes("*");if(e.length===0&&!t)return null;let r=t?["* (all crawlers)"]:e;return w("ai-bots-blocked","aeo","critical","AI crawlers blocked in robots.txt",`robots.txt disallows ${r.join(", ")} from the whole site. Blocked AI crawlers (GPTBot, ClaudeBot, PerplexityBot\u2026) mean your content can never be cited in AI answers.`,"Unless exclusion is deliberate policy, allow AI answer-engine crawlers in robots.txt.",[{location:`${n.origin}/robots.txt`,snippet:r.map(o=>`User-agent: ${o} / Disallow: /`).join("; ")}])},Hn=n=>n.llmsTxtExists?null:w("llms-txt-missing","aeo","info","No llms.txt","The emerging llms.txt convention gives AI systems a curated, markdown map of your most important content. Early adopters get disproportionate representation in AI answers.","Publish /llms.txt with a short site description and links to key pages in markdown.",[{location:`${n.origin}/llms.txt`,snippet:"not found"}]),Un=n=>I(n).some(t=>pe(t).some(r=>["FAQPage","HowTo","QAPage","Article","BlogPosting"].includes(r)))?null:w("no-answer-schema","aeo","warning","No FAQ/HowTo/Article schema on any page","No crawled page uses answer-oriented schema (FAQPage, HowTo, QAPage, Article). These formats map directly onto the question\u2192answer shape AI assistants extract.","Mark up FAQ sections with FAQPage schema and articles with Article schema.",[{location:n.origin}]),Bn=n=>{let e=I(n);return e.length===0||e.filter(r=>r.headings.some(o=>/\?\s*$/.test(o.text)||/^(what|how|why|when|where|who|which|can|should|is|are|does|do)\b/i.test(o.text))).length>0?null:w("no-question-headings","aeo","info","No question-style headings found","None of the crawled pages use question-phrased headings. Answer engines strongly prefer content whose structure mirrors the user's question.",'Add sections with headings like "How does X work?" followed by a direct 2\u20133 sentence answer.',[{location:n.origin}])},Y=n=>R(n,e=>e.wordCount>300),Kn=n=>{let e=Y(n);return e.length===0||e.some(t=>t.hasAuthorSignal)?null:w("no-author-signals","aeo","warning","No author or expertise attribution anywhere","None of the content pages carry author signals (byline, meta author, JSON-LD author). Expert attribution is a measured AI-citation lever (~+30% for attributed quotes) and core to E-E-A-T.","Add visible bylines with credentials to substantive content, plus Person/author JSON-LD.",e.slice(0,5).map(t=>({location:t.url})),e.length)},zn=n=>{let e=I(n).filter(o=>o.contentDate);if(e.length===0)return Y(n).length===0?null:w("no-freshness-signals","aeo","info","No machine-readable freshness signals","No crawled page exposes a published/modified date (article:modified_time or JSON-LD dateModified). AI engines cite recently-updated content ~3x more often; without dates, freshness can't be credited.","Emit dateModified in JSON-LD (or article:modified_time meta) and keep it truthful.",[{location:n.origin}]);let t=Date.now()-183*864e5,r=e.filter(o=>Date.parse(o.contentDate)<t);return r.length===0?null:w("stale-content","aeo","info","Content not updated in over 6 months",`${r.length} page(s) declare a modified date older than 6 months. Studies of AI citations show stale pages progressively lose citation eligibility.`,"Refresh and re-date the most important pages (genuinely update them \u2014 don't just bump the date).",r.slice(0,8).map(o=>({location:o.url,snippet:o.contentDate})),r.length)},Wn=n=>{let e=R(n,t=>!t.hasMainLandmark&&t.wordCount>100);return e.length===0?null:w("no-main-landmark","technical","info","Pages without semantic landmarks",`${e.length} page(s) have no <main>/<article> landmark. Semantic structure helps both accessibility and AI agents isolate the primary content from chrome.`,"Wrap primary content in <main> (and articles in <article>).",e.map(t=>({location:t.url})),e.length)},Vn=n=>{let t=R(n,r=>{let o=`${r.title??""} ${r.h1s.join(" ")}`.toLowerCase();return/\bvs\.?\b|\bversus\b|\bbest \b|\btop \d|\bcomparison\b|\balternatives?\b/.test(o)&&r.wordCount>200}).filter(r=>r.tableCount===0);return t.length===0?null:w("comparison-without-table","aeo","info","Comparison-intent pages without comparison tables",`${t.length} page(s) target comparison queries (vs/best/top/alternatives) but contain no table. Tables beat prose for comparison extraction, and comparison content earns ~a third of AI citations.`,"Add a structured comparison table with the entities and attributes being compared.",t.map(r=>({location:r.url,snippet:r.title})),t.length)},Qn=n=>{let e=Y(n).filter(r=>r.sectionWordCounts.length>=2);if(e.length===0)return null;let t=e.filter(r=>!r.sectionWordCounts.some(o=>o>=30&&o<=170));return t.length===0?null:w("poor-answer-blocks","aeo","info","No extractable answer blocks",`${t.length} structured page(s) have no section in the ~40\u2013170 word range AI engines prefer to extract (their sections are all very short fragments or very long walls).`,"Lead each H2/H3 section with a self-contained 40\u201360 word direct answer before elaborating.",t.map(r=>({location:r.url,snippet:`sections: ${r.sectionWordCounts.slice(0,8).join(", ")} words`})),t.length)},Yn=/^[a-z]{2,3}(-[a-z]{2}|-\d{3})?$|^x-default$/i,X=n=>R(n,e=>e.hreflang.length>0),Xn=n=>{let e=X(n).map(t=>({p:t,bad:t.hreflang.filter(r=>!Yn.test(r.lang)||/^en-uk$/i.test(r.lang)||r.lang.includes("_"))})).filter(t=>t.bad.length>0);return e.length===0?null:w("hreflang-invalid-codes","technical","warning","Invalid hreflang language codes",`${e.length} page(s) declare hreflang values that aren't valid ISO codes (common mistakes: en-UK instead of en-GB, underscores instead of hyphens). Invalid codes are silently ignored, breaking the whole cluster.`,"Use ISO 639-1 language + optional ISO 3166-1 region, hyphen-separated (en, en-GB, fr-CA) or x-default.",e.slice(0,8).map(t=>({location:t.p.url,snippet:t.bad.map(r=>r.lang).join(", ")})),e.length)},Zn=n=>{let e=X(n).filter(t=>!t.hreflang.some(r=>r.href.replace(/\/$/,"")===t.url.replace(/\/$/,"")));return e.length===0?null:w("hreflang-no-self-reference","technical","warning","hreflang sets without a self-referencing entry",`${e.length} page(s) declare hreflang alternates but omit the required self-referencing entry. Google treats sets without self-reference as invalid.`,"Every page in an hreflang cluster must include itself in its own alternate list.",e.slice(0,8).map(t=>({location:t.url})),e.length)},er=n=>{let e=X(n).filter(t=>!t.hreflang.some(r=>r.lang.toLowerCase()==="x-default"));return e.length===0?null:w("hreflang-no-x-default","technical","info","hreflang sets without x-default",`${e.length} page(s) declare language alternates without an x-default fallback for unmatched locales.`,'Add <link rel="alternate" hreflang="x-default" href="..."> pointing at the language-selector or primary version.',e.slice(0,8).map(t=>({location:t.url})),e.length)},tr=n=>{let e=new Map(I(n).map(r=>[r.url.replace(/\/$/,""),r])),t=[];for(let r of X(n))for(let o of r.hreflang){let i=e.get(o.href.replace(/\/$/,""));if(!i||i.url===r.url)continue;i.hreflang.some(a=>a.href.replace(/\/$/,"")===r.url.replace(/\/$/,""))||t.push({from:r.url,to:i.url})}return t.length===0?null:w("hreflang-not-reciprocal","technical","warning","hreflang links without return tags",`${t.length} hreflang link(s) between crawled pages are not reciprocated. Without return tags, Google ignores the annotation entirely.`,"Every alternate must link back: if A lists B, B must list A.",t.slice(0,8).map(r=>({location:r.from,snippet:`\u2192 ${r.to} does not link back`})),t.length)},nr=n=>{let e=X(n).filter(t=>!!t.canonical&&t.canonical.replace(/\/$/,"")!==t.url.replace(/\/$/,""));return e.length===0?null:w("hreflang-canonical-conflict","technical","warning","hreflang pages canonicalizing to a different URL",`${e.length} page(s) in hreflang clusters set a canonical pointing away from themselves. Cross-URL canonicals inside hreflang sets tell Google to ignore that locale version.`,"Localized pages must self-canonicalize; never canonicalize one locale to another.",e.slice(0,8).map(t=>({location:t.url,snippet:`canonical: ${t.canonical}`})),e.length)},at=n=>R(n,e=>{let t=new URL(e.url).pathname.toLowerCase(),r=(e.title??"").toLowerCase();return(/pricing|plans|prices/.test(t)||/pricing|plans\b/.test(r))&&e.wordCount>30}),ct=/[$€£¥₹]\s?\d|\d+\s?(?:\/|per\s)\s?(?:mo|month|user|seat|yr|year)\b|free\s+(?:plan|tier|forever)/i,rr=n=>{let e=at(n).filter(t=>!ct.test(t.textSample));return e.length===0?null:w("prices-not-machine-readable","aeo","warning","Pricing pages without machine-readable prices",`${e.length} pricing page(s) contain no readable price in their HTML text. If prices live in images, JS-only widgets, or behind "Contact us", AI agents answering "what does this cost?" will skip you for a competitor whose prices they can read.`,`Put real prices in HTML text (and ideally Offer JSON-LD). Quick self-test: ask an AI assistant "what are the plans and prices on <url>?" \u2014 if it can't answer, agents can't either.`,e.map(t=>({location:t.url,snippet:t.title})),e.length)},or=n=>{let t=at(n).filter(r=>{let o=JSON.stringify(r.jsonLd);return ct.test(r.textSample)&&!/"Offer"|"AggregateOffer"|"PriceSpecification"|"priceCurrency"/.test(o)});return t.length===0?null:w("pricing-no-offer-schema","structured-data","info","Pricing pages without Offer schema",`${t.length} pricing page(s) show prices in text but don't declare them in Offer/PriceSpecification JSON-LD, leaving extraction to guesswork.`,"Add Offer JSON-LD per plan (name, price, priceCurrency, billing period).",t.map(r=>({location:r.url})),t.length)},ir=["in today's digital landscape","in today's fast-paced world","in this day and age","it's important to note that","it is worth mentioning","when it comes to","at the end of the day","unlock the power of","unlock your potential","game-changer","revolutionize the way","seamlessly integrate","elevate your","harness the power","navigate the complexities","a testament to","ever-evolving landscape","cutting-edge technology","take your business to the next level","look no further","delve into","embark on a journey"],sr=n=>{let e=Y(n).map(t=>{let r=t.textSample.toLowerCase(),o=ir.filter(i=>r.includes(i));return{p:t,hits:o}}).filter(t=>t.hits.length>=2);return e.length===0?null:w("filler-content","content","info","Generic filler phrasing detected",`${e.length} page(s) lean on stock marketing/AI filler phrases. Generic phrasing depresses reader trust and gives AI engines nothing distinctive to cite.`,"Replace filler with specific claims: concrete numbers, named examples, and plain statements of what the thing does.",e.slice(0,8).map(t=>({location:t.p.url,snippet:t.hits.slice(0,3).map(r=>`"${r}"`).join(", ")})),e.length)},ar=n=>{let e=Y(n).filter(t=>t.wordCount>500&&!/\d/.test(t.textSample));return e.length===0?null:w("no-concrete-data","content","info","Long-form pages without a single number",`${e.length} page(s) over 500 words contain no numeric data at all. Specific statistics (with sources) are among the strongest measured AI-citation levers; prose without data reads as opinion.`,"Add concrete data points \u2014 metrics, dates, prices, benchmark results \u2014 with their sources.",e.slice(0,8).map(t=>({location:t.url})),e.length)},O=n=>n.replace(/\/$/,"");function lt(n){let e=new Set(I(n).map(r=>O(r.url))),t=new Map;for(let r of I(n)){let o=new Set;for(let i of r.internalLinkDetails){let s=O(i.href);!e.has(s)||s===O(r.url)||o.has(s)||(o.add(s),t.set(s,[...t.get(s)??[],{from:r.url,anchor:i.anchor}]))}}return{incoming:t,crawledSet:e}}var Re=10,cr=n=>{if(I(n).length<Re)return null;let{incoming:e}=lt(n),t=O(n.origin),r=I(n).filter(o=>O(o.url)!==t&&(e.get(O(o.url))?.length??0)<3);return r.length===0?null:w("weak-incoming-links","content","info","Pages with fewer than 3 incoming internal links",`${r.length} crawled page(s) receive fewer than 3 internal links from other crawled pages. Pages with little internal linking accumulate less authority and are treated as unimportant. (Counts reflect the crawled sample, not the full site.)`,"Link to these pages from related content and hub pages \u2014 every substantive page should have at least 3 incoming internal links.",r.slice(0,8).map(o=>({location:o.url,snippet:`${e.get(O(o.url))?.length??0} incoming`})),r.length)},lr=n=>{if(I(n).length<Re)return null;let{incoming:e}=lt(n),t=[];for(let[r,o]of e){if(o.length<5)continue;let i=new Map;for(let s of o){let a=s.anchor.toLowerCase().trim();a&&i.set(a,(i.get(a)??0)+1)}for(let[s,a]of i)if(a/o.length>.4&&s.length>2){t.push({url:r,anchor:s,share:Math.round(a/o.length*100)});break}}return t.length===0?null:w("anchor-over-optimization","content","info","Repetitive anchor text to the same pages",`${t.length} page(s) receive over 40% of their internal links with the identical anchor text. Anchor diversity reads as natural; repetition reads as over-optimization.`,"Vary the anchors linking to these pages (mix branded, descriptive, and partial-match anchors).",t.slice(0,8).map(r=>({location:r.url,snippet:`"${r.anchor}" = ${r.share}% of incoming anchors`})),t.length)},dr=n=>{let e=I(n);if(e.length<Re)return null;let t=O(n.origin),r=new Map;for(let a of e)r.set(O(a.url),a.internalLinkDetails.map(d=>O(d.href)));let o=new Map([[t,0]]),i=[t];for(;i.length>0;){let a=i.shift();for(let d of r.get(a)??[])!o.has(d)&&r.has(d)&&(o.set(d,o.get(a)+1),i.push(d))}let s=e.filter(a=>(o.get(O(a.url))??99)>2);return s.length===0?null:w("deep-pages","content","info","Pages more than 2 clicks from the homepage",`${s.length} crawled page(s) are not reachable within 2 clicks of the homepage (via crawled links). Depth dilutes crawl priority and authority flow.`,"Surface these pages through hub pages, navigation, or contextual links so everything important is within 2 clicks.",s.slice(0,8).map(a=>({location:a.url,snippet:`depth ${o.get(O(a.url))??"unreachable"}`})),s.length)},pr=n=>{let e=I(n);if(e.length===0)return null;let t=e.filter(r=>r.wordCount<40&&r.title);return t.length<Math.max(2,e.length*.5)?null:w("client-rendered-content","aeo","critical","Content appears to be client-side rendered",`${t.length} of ${e.length} pages deliver almost no text in their HTML. Many AI crawlers do not execute JavaScript, so they see empty pages.`,"Server-render or prerender your pages (SSR/SSG) so full content is present in the initial HTML.",t.slice(0,8).map(r=>({location:r.url,snippet:`${r.wordCount} words in HTML`})),t.length)},ur=[mn,hn,fn,yn,wn,bn,vn,kn,An,Sn,xn,Pn,$n,Rn,Cn,In,En,jn,On,Ln,Tn,_n,Mn,Nn,Dn,Gn,qn,Jn,Hn,Un,Bn,Kn,zn,Wn,Vn,Qn,Xn,Zn,er,tr,nr,rr,or,sr,ar,cr,lr,dr,pr];function dt(n){let e=[];for(let t of ur){let r=t(n);r&&e.push(r)}return e}var pt={technical:"Technical SEO",content:"On-Page Content","structured-data":"Structured Data",aeo:"AEO Readiness",performance:"Performance"},ut={technical:.25,content:.25,"structured-data":.15,aeo:.2,performance:.15},gr={critical:25,warning:9,info:3,pass:0};function mr(n){let e=gr[n.severity]??0,t=Math.min(1.8,1+Math.log10(Math.max(1,n.occurrences))*.5),r=n.disputed?.4:1;return e*t*r}function ue(n,e={}){let t=s=>s!=="performance"||e.performanceMeasured||n.some(a=>a.category==="performance"),r=Object.keys(pt).filter(t).map(s=>{let a=n.filter(d=>d.category===s).reduce((d,m)=>d+mr(m),0);return{category:s,label:pt[s],score:Math.max(0,Math.round(100-a))}}),o=r.reduce((s,a)=>s+ut[a.category],0);return{overall:Math.round(r.reduce((s,a)=>s+a.score*ut[a.category],0)/o),categories:r}}async function gt(n,e,t=6e4){let r=new URL("https://www.googleapis.com/pagespeedonline/v5/runPagespeed");r.searchParams.set("url",n),r.searchParams.set("strategy","mobile"),r.searchParams.set("category","performance"),e&&r.searchParams.set("key",e);let o=await fetch(r,{signal:AbortSignal.timeout(t)});if(o.status===429)throw new Error(e?"PageSpeed API quota exceeded for your key":"PageSpeed keyless quota exhausted \u2014 add a free Google API key in Settings to enable performance measurement");if(!o.ok){let p=await o.text().catch(()=>"");throw new Error(`PageSpeed API ${o.status}: ${p.slice(0,160)}`)}let i=await o.json(),s=i.loadingExperience?.metrics??{},a=s.LARGEST_CONTENTFUL_PAINT_MS?.percentile,d=s.INTERACTION_TO_NEXT_PAINT?.percentile,m=s.CUMULATIVE_LAYOUT_SHIFT_SCORE?.percentile,y=i.lighthouseResult?.categories?.performance?.score;return{url:n,lighthouseScore:y!==void 0?Math.round(y*100):void 0,lcpMs:a,inpMs:d,cls:m!==void 0?m/100:void 0,fieldDataAvailable:Object.keys(s).length>0}}var hr=0;function ge(n,e,t,r,o,i,s){return{id:`pf-${++hr}-${n}`,ruleId:n,category:"performance",severity:e,title:t,description:r,recommendation:o,evidence:[{location:i,snippet:s}],occurrences:1,source:"rule"}}function mt(n){let e=[];return n.lighthouseScore!==void 0&&n.lighthouseScore<80&&e.push(ge("perf-lighthouse",n.lighthouseScore<50?"critical":"warning",`Lighthouse performance score ${n.lighthouseScore}/100`,"Lab performance is below Google's 'good' band. Slow pages lose rankings, conversions, and crawl budget.","Run PageSpeed Insights for the detailed opportunity list (render-blocking resources, image sizing, JS payload) and fix the top items.",n.url,`Lighthouse ${n.lighthouseScore}/100 (mobile)`)),n.lcpMs!==void 0&&n.lcpMs>2500&&e.push(ge("perf-lcp",n.lcpMs>4e3?"critical":"warning","Largest Contentful Paint is too slow (field data)",`Real users experience LCP of ${(n.lcpMs/1e3).toFixed(1)}s; Google's 'good' threshold is 2.5s. LCP is a confirmed ranking signal.`,"Optimize the hero image/text delivery: preload the LCP resource, use a CDN, compress images, and cut render-blocking CSS/JS.",n.url,`LCP p75 = ${(n.lcpMs/1e3).toFixed(1)}s`)),n.inpMs!==void 0&&n.inpMs>200&&e.push(ge("perf-inp",n.inpMs>500?"critical":"warning","Interaction to Next Paint is too slow (field data)",`Real-user INP is ${n.inpMs}ms; 'good' is under 200ms. Sluggish interactions hurt the page experience signal.`,"Break up long JavaScript tasks, defer non-critical scripts, and minimize main-thread work on interaction handlers.",n.url,`INP p75 = ${n.inpMs}ms`)),n.cls!==void 0&&n.cls>.1&&e.push(ge("perf-cls",n.cls>.25?"critical":"warning","Cumulative Layout Shift is too high (field data)",`Real-user CLS is ${n.cls.toFixed(2)}; 'good' is under 0.10. Shifting layouts frustrate users and hurt rankings.`,"Set explicit width/height on images and embeds, reserve space for ads/banners, and avoid injecting content above existing content.",n.url,`CLS p75 = ${n.cls.toFixed(2)}`)),e}async function Z(n,e,t){let r=Je(e),o=t.onProgress??(()=>{});async function i(p,g,c){let l=Date.now();o(p,g);let u=setInterval(()=>{let h=Math.round((Date.now()-l)/1e3);o(`${p} \u2014 ${h}s elapsed, still working`,g)},5e3);u.unref?.();try{return await c()}finally{clearInterval(u)}}o(`Crawling ${r}${t.render?" (JS rendering)":""}`,.05);let s=await He(r,{maxPages:t.maxPages,timeoutMs:t.timeoutMs,render:t.render,onProgress:(p,g,c)=>o(`Crawled ${p}/${g}: ${c}`,.05+.45*(p/g))});if(s.pages.length===0)throw new Error(`Could not fetch any page from ${r} \u2014 is the domain reachable?`);o(`Round 1: rule engine over ${s.pages.length} pages`,.5);let a=dt(s),d=!1;if(t.measurePerformance)try{let p=`psi:${s.origin}`,g=t.cache?.kvGet(p,864e5)??null;g||(g=await i("Measuring Core Web Vitals (PageSpeed Insights)",.55,()=>gt(s.origin,t.psiApiKey)),t.cache?.kvSet(p,g)),a=[...a,...mt(g)],d=g.lighthouseScore!==void 0||g.fieldDataAvailable,o(`Performance: Lighthouse ${g.lighthouseScore??"n/a"}${g.fieldDataAvailable?", CrUX field data available":""}`,.6)}catch(p){o(`PageSpeed measurement failed (${p instanceof Error?p.message:p}); skipping performance category`,.6)}if(n&&!t.skipLlm){try{let p=await i(`Round 2: AI auditor reviewing ${s.pages.length} crawled pages`,.65,()=>Ue(n,s,a));a=[...a,...p],o(`AI auditor added ${p.length} finding(s)`,.74)}catch(p){t.onIssue?.("AI audit round failed",p),o(`AI audit round failed (${p instanceof Error?p.message:p}); continuing with rule findings`,.74)}try{a=await i("Dissenter lens reviewing rule findings for false positives",.76,()=>Be(n,s,a));let p=a.filter(g=>g.disputed).length;p>0&&o(`Dissenter disputed ${p} rule finding(s) as inapplicable here`,.78)}catch{}try{let p=await i("Running the AI paste test on the pricing page",.8,()=>Ke(n,s));p.length>0&&(a=[...a,...p],o("Pricing page failed the AI paste test",.8))}catch{}}let m=ue(a,{performanceMeasured:d}),y=Me.parse({id:fr(),type:"domain",domain:r,createdAt:new Date().toISOString(),pagesCrawled:s.pages.length,pages:s.pages.map(p=>({url:p.url,status:p.status,title:p.title,metaDescription:p.metaDescription,h1:p.h1s[0],wordCount:p.wordCount,canonical:p.canonical,metaRobots:p.metaRobots,jsonLdCount:p.jsonLd.length,hasOg:Object.keys(p.og).length>0})),scorecard:m,findings:a});if(n&&!t.skipLlm)try{y.llmReport=await i("Writing the narrative report and action plan",.85,()=>oe(n,{subject:r,scorecard:m,findings:a,contentSamples:s.pages.slice(0,5).map(p=>({location:p.url,sample:p.textSample}))}))}catch(p){t.onIssue?.("AI report failed",p),o(`AI report failed (${p instanceof Error?p.message:p}); returning rule-based report`,.95)}return o("Done",1),y}import{randomUUID as yr}from"crypto";var wr={type:"object",additionalProperties:!1,required:["keywords","gaps","summary"],properties:{summary:{type:"string",description:"3-4 sentence overview of keyword coverage and the biggest opportunity"},keywords:{type:"array",items:{type:"object",additionalProperties:!1,required:["keyword","intent","coverage","aeoCitable","notes"],properties:{keyword:{type:"string"},intent:{type:"string",enum:["informational","navigational","transactional","commercial"]},coverage:{type:"string",enum:["strong","partial","missing"],description:"How well the crawled site currently covers this keyword"},bestPage:{type:"string",description:"URL of the best existing page for this keyword, omit if none"},aeoCitable:{type:"boolean",description:"Would an AI answer engine plausibly cite this site for this query today?"},notes:{type:"string",description:"Specific advice for this keyword on this site"},stage:{type:"string",enum:["awareness","consideration","decision","implementation"],description:"Buyer-journey stage: awareness (what is/how to/guide), consideration (best/top/vs/alternatives), decision (pricing/reviews/demo/trial), implementation (templates/examples/tutorial/setup)"},fanOut:{type:"array",maxItems:5,description:"AI engines fan a query out into related sub-queries. List the 3-5 sub-queries this keyword triggers, and whether the crawled site has content covering each.",items:{type:"object",additionalProperties:!1,required:["query","covered"],properties:{query:{type:"string"},covered:{type:"boolean",description:"Does any crawled page substantively cover this sub-query?"}}}}}}},gaps:{type:"array",description:"High-value related keywords the site does NOT target but should",items:{type:"object",additionalProperties:!1,required:["keyword","rationale"],properties:{keyword:{type:"string"},rationale:{type:"string"}}}}}};async function ht(n,e,t,r={}){let o="You are a senior SEO/AEO keyword strategist. You are given a crawled snapshot of a website (page titles, descriptions, headings, word counts) and a list of target keywords. Analyze how well the site covers each keyword for both classic search and AI answer engines. Base every judgment strictly on the provided pages; if the site has no relevant page, say coverage is missing.",i=e.pages.map(y=>({url:y.url,title:y.title,description:y.metaDescription,h1:y.h1,words:y.wordCount})),s=r.gscQueries&&r.gscQueries.length>0?`REAL Google Search Console data for this site (last 90 days, top queries):
27
+ ${r.gscQueries.slice(0,60).map(y=>`- "${y.query}": ${y.impressions} impressions, ${y.clicks} clicks, avg position ${y.position.toFixed(1)}`).join(`
27
28
  `)}
28
29
  Use this real data: queries with high impressions but poor position/CTR are prime opportunities; include the best of them in your analysis even if not in the target list.`:null,a=[`Site: ${e.domain}`,`Crawled pages (JSON):
29
30
  ${JSON.stringify(i,null,1)}`,s,t.length>0?`Target keywords: ${t.join(", ")}`:"No target keywords were provided: infer the 8-12 keywords this site is implicitly targeting (prioritize real Search Console queries when given) and analyze those.","For each keyword: classify intent AND buyer-journey stage, rate coverage, name the best existing page (exact URL from the list, or omit), judge AEO citability, and give one specific note. Also list the 3-5 fan-out sub-queries AI engines would generate for it and judge \u2014 strictly from the crawled pages \u2014 whether each is covered: AI features answer with the full topical cluster, so uncovered fan-out queries are invisible-surface losses even when the head keyword is covered. Then list up to 8 gap keywords with rationale."].filter(Boolean).join(`
30
31
 
31
- `),d=await n.analyzeJson({system:o,prompt:a,jsonSchema:Vn}),m=Ce.parse({id:Wn(),type:"keywords",auditId:e.id,domain:e.domain,createdAt:new Date().toISOString(),...d});if(r.volumes)for(let w of m.keywords){let u=r.volumes.get(w.keyword.toLowerCase());u!==void 0&&(w.searchVolume=u)}return m}import L from"fs";import D from"path";import{randomUUID as Qn}from"crypto";var Yn=new Set(["node_modules",".git",".next","dist","build","out",".vercel",".turbo","coverage",".cache","vendor","venv",".venv","__pycache__","wp-admin","wp-includes","storage","tmp","log"]),Xn=new Set([".html",".htm",".jsx",".tsx",".js",".ts",".mjs",".astro",".vue",".svelte",".md",".mdx",".php",".twig",".liquid",".erb",".ejs",".hbs",".py",".rb"]),it=500;function Zn(n,e){let t=(...i)=>i.some(s=>L.existsSync(D.join(e,s))),r=(i,s)=>{try{return L.readFileSync(D.join(e,i),"utf8").toLowerCase().includes(s)}catch{return!1}};if(t("wp-config.php","wp-content")||r("style.css","theme name:"))return"WordPress";if(t("artisan"))return"Laravel";if(t("manage.py"))return"Django";if(r("requirements.txt","flask")||r("pyproject.toml","flask"))return"Flask";if(r("requirements.txt","fastapi")||r("pyproject.toml","fastapi"))return"FastAPI";if(r("Gemfile","rails"))return"Ruby on Rails";if(t("config.rb")&&r("Gemfile","middleman"))return"Middleman";if(t("_config.yml"))return"Jekyll";if(t("config.toml","hugo.toml")&&t("content"))return"Hugo";let o={...n?.dependencies??{},...n?.devDependencies??{}};return o.next?"Next.js":o.astro?"Astro":o.nuxt?"Nuxt":o["@sveltejs/kit"]?"SvelteKit":o.gatsby?"Gatsby":o["@remix-run/react"]||o["react-router"]?"Remix/React Router":o.express||o.fastify||o.hono||o.koa?"Node.js server":o.react&&o.vite?"React (Vite SPA)":o.react?"React":o.vue?"Vue":t("composer.json")||t("index.php")?"PHP":L.existsSync(D.join(e,"index.html"))?"Static HTML":"Unknown"}function pe(n){let e=D.resolve(n);if(!L.existsSync(e)||!L.statSync(e).isDirectory())throw new Error(`Not a directory: ${e}`);let t=[],r=i=>{if(t.length>=it)return;let s;try{s=L.readdirSync(i,{withFileTypes:!0})}catch{return}for(let a of s){if(t.length>=it)return;if(a.name.startsWith(".")&&a.name!==".well-known")continue;let d=D.join(i,a.name);if(a.isDirectory())Yn.has(a.name)||r(d);else{let m=D.extname(a.name).toLowerCase();if(!Xn.has(m))continue;try{if(L.statSync(d).size>4e5)continue;t.push({rel:D.relative(e,d),abs:d,ext:m,content:L.readFileSync(d,"utf8")})}catch{}}}};r(e);let o=null;try{o=JSON.parse(L.readFileSync(D.join(e,"package.json"),"utf8"))}catch{}return{root:e,framework:Zn(o,e),files:t,packageJson:o,hasPublicFile:i=>L.existsSync(D.join(e,"public",i))||L.existsSync(D.join(e,"static",i))||L.existsSync(D.join(e,i))}}var er=0;function E(n,e,t,r,o,i,s,a=s.length||1){return{id:`cf-${++er}-${n}`,ruleId:n,category:e,severity:t,title:r,description:o,recommendation:i,evidence:s.slice(0,8),occurrences:a,source:"rule"}}function tr(n){return n.files.some(e=>/^(src\/)?app\/layout\.(tsx|jsx|js|ts)$/.test(e.rel))}function ue(n){let e=[],t=n.framework==="Next.js",r=t&&tr(n),o=["WordPress","Ruby on Rails","Django","Laravel"].includes(n.framework),i=n.files.filter(g=>[".jsx",".tsx",".astro",".vue",".svelte",".html",".htm",".php",".twig",".liquid",".erb",".ejs",".hbs"].includes(g.ext)),s=n.files.map(g=>g.content).join(`
32
- `);if(!(n.hasPublicFile("robots.txt")||t&&n.files.some(g=>/^(src\/)?app\/robots\.(ts|js|tsx)$/.test(g.rel)))&&!o&&e.push(E("cb-robots-missing","technical","warning","No robots.txt in the project","The project ships no robots.txt (static or generated), so crawlers get no guidance and no sitemap pointer.",t?"Add an app/robots.ts using Next.js Metadata routes (or public/robots.txt).":"Add a robots.txt to the public/static directory.",[{location:"public/robots.txt",snippet:"not found"}])),!(n.hasPublicFile("sitemap.xml")||t&&n.files.some(g=>/^(src\/)?app\/sitemap\.(ts|js|tsx)$/.test(g.rel))||JSON.stringify(n.packageJson?.dependencies??{}).includes("next-sitemap"))&&!o&&e.push(E("cb-sitemap-missing","technical","warning","No sitemap generation","No sitemap.xml or sitemap generation code was found; new pages will be discovered slowly.",t?"Add an app/sitemap.ts Metadata route that enumerates your routes.":"Generate a sitemap.xml at build time and ship it in public/.",[{location:"public/sitemap.xml",snippet:"not found"}])),n.hasPublicFile("llms.txt")||e.push(E("cb-llms-txt","aeo","info","No llms.txt","The project doesn't publish an llms.txt \u2014 an emerging convention giving AI systems a curated map of your content.","Add public/llms.txt with a short markdown description of the site and links to key pages.",[{location:"public/llms.txt",snippet:"not found"}])),r){let g=n.files.filter(c=>/^(src\/)?app\/.*(layout|page)\.(tsx|jsx|js|ts)$/.test(c.rel));if(g.filter(c=>/export\s+(const|async function|function)\s+(metadata|generateMetadata)\b/.test(c.content)).length===0)e.push(E("cb-no-metadata-export","technical","critical","No Metadata API usage in the App Router","No layout or page exports `metadata`/`generateMetadata`, so pages ship without titles and descriptions.","Export `metadata` from app/layout.tsx (site defaults) and per-page metadata (or generateMetadata) for every route.",[{location:"app/layout.tsx"}]));else{let p=g.filter(h=>/page\.(tsx|jsx|js|ts)$/.test(h.rel)).filter(h=>!/export\s+(const|async function|function)\s+(metadata|generateMetadata)\b/.test(h.content));p.length>0&&e.push(E("cb-pages-without-metadata","technical","warning","Routes without their own metadata",`${p.length} page(s) rely on inherited metadata, so several routes share the same title/description.`,"Export route-specific `metadata` (title, description, openGraph) from each page.tsx.",p.map(h=>({location:h.rel})),p.length))}}else{let g=n.files.filter(h=>[".html",".htm"].includes(h.ext)),l=g.filter(h=>!/<title[\s>]/i.test(h.content));l.length>0&&e.push(E("cb-html-no-title","technical","critical","HTML files without <title>",`${l.length} HTML file(s) have no title tag.`,"Add a unique, descriptive <title> to every HTML document.",l.map(h=>({location:h.rel})),l.length));let c=g.filter(h=>!/name=["']description["']/i.test(h.content));c.length>0&&e.push(E("cb-html-no-description","technical","warning","HTML files without a meta description",`${c.length} HTML file(s) lack a meta description.`,'Add <meta name="description" content="..."> to every HTML document.',c.map(h=>({location:h.rel})),c.length));let p=g.filter(h=>!/name=["']viewport["']/i.test(h.content));p.length>0&&e.push(E("cb-html-no-viewport","technical","critical","HTML files without a viewport meta tag",`${p.length} HTML file(s) lack the responsive viewport tag; mobile-first indexing will penalize them.`,'Add <meta name="viewport" content="width=device-width, initial-scale=1">.',p.map(h=>({location:h.rel})),p.length))}let m=[],w=0;for(let g of i){let c=(g.content.match(/<(?:img|Image)\b[^>]*>/g)??[]).filter(p=>!/\balt\s*=/.test(p));c.length>0&&(w+=c.length,m.push({location:g.rel,snippet:c[0].slice(0,120)}))}if(w>0&&e.push(E("cb-img-no-alt","content","warning","Image elements without alt text",`${w} <img>/<Image> element(s) across ${m.length} file(s) have no alt attribute.`,'Add descriptive alt text to meaningful images; use alt="" for decorative ones.',m,w)),t){let g=i.filter(l=>/<img\b/.test(l.content));g.length>0&&e.push(E("cb-raw-img-next","technical","info","Raw <img> instead of next/image",`${g.length} file(s) use raw <img> tags; next/image provides automatic optimization, lazy loading and correct sizing.`,"Replace raw <img> with the next/image <Image> component where practical.",g.map(l=>({location:l.rel})),g.length))}return/application\/ld\+json|schema\.org/i.test(s)||e.push(E("cb-no-jsonld","structured-data","warning","No JSON-LD structured data in the codebase","No file references schema.org or emits application/ld+json. Structured data is a primary input for rich results and AI answer attribution.","Add Organization + WebSite JSON-LD to the root layout, and page-type schema (Article, FAQPage\u2026) where relevant.",[{location:n.root}])),/og:|openGraph/i.test(s)||e.push(E("cb-no-og","structured-data","warning","No Open Graph tags","No Open Graph configuration was found, so link shares render without preview cards.",r?"Add an `openGraph` section to your metadata exports (title, description, images).":"Add og:title, og:description and og:image meta tags to your page heads.",[{location:n.root}])),/canonical|alternates/i.test(s)||e.push(E("cb-no-canonical","technical","info","No canonical URL handling","No canonical link handling was found; duplicate URL variants may compete with each other.",r?"Set `metadataBase` and `alternates.canonical` in your metadata exports.":'Emit <link rel="canonical"> on each page.',[{location:n.root}])),e}function ge(n,e){return Ie.parse({id:Qn(),type:"codebase",path:n.root,framework:n.framework,createdAt:new Date().toISOString(),filesScanned:n.files.length,scorecard:le(e),findings:e})}import nr from"fs";import rr from"path";import{execFile as or}from"child_process";import{promisify as ir}from"util";var sr=ir(or);async function G(n,...e){let{stdout:t}=await sr("git",e,{cwd:n,maxBuffer:10485760});return t}async function at(n){try{return await G(n,"rev-parse","--is-inside-work-tree"),!0}catch{return!1}}async function ct(n){return(await G(n,"status","--porcelain")).trim()===""}async function ke(n){return(await G(n,"status","--porcelain")).split(`
33
- `).map(t=>t.trim()).filter(Boolean).map(t=>t.replace(/^[A-Z?!]{1,2}\s+/,"").replace(/^"|"$/g,""))}async function ar(n,e){return e.length===0?"":(await G(n,"add","-N","--",...e).catch(()=>{}),G(n,"diff","--",...e))}async function lt(n,e){if(e.length===0)return;await G(n,"reset","--",...e).catch(()=>{}),await G(n,"checkout","--",...e).catch(()=>{});let t=await ke(n),r=e.filter(o=>t.includes(o));r.length>0&&await G(n,"clean","-f","--",...r).catch(()=>{})}var st={"cb-llms-txt":'llms.txt format (llmstxt.org convention): a markdown file at the site root/public dir starting with "# <Site name>", then a one-paragraph blockquote summary of what the site/product is, then "## Docs"/"## Pages" sections containing "- [Page title](absolute-url): one-line description" entries for the most important pages. Derive names, URLs and descriptions from the actual project \u2014 do not invent pages.',"cb-robots-missing":"robots.txt: allow all well-behaved crawlers by default, include AI answer-engine crawlers (do NOT disallow GPTBot/ClaudeBot/PerplexityBot unless the project clearly intends to block them), and end with a Sitemap: line pointing at the site's sitemap URL.","cb-sitemap-missing":"Sitemap: enumerate the project's real routes (inspect the routing/pages structure) with absolute URLs. For frameworks with dynamic routes, generate from the route source rather than hardcoding a stale list.","cb-no-jsonld":"JSON-LD: add Organization + WebSite schema in the site-wide layout/head (name, url, logo, sameAs from real social links found in the project). Use https://schema.org @context, absolute URLs, and no placeholder text.","cb-no-og":"Open Graph: og:title, og:description, og:type, og:url, og:image (pick a real existing image asset from the project \u2014 do not reference files that don't exist)."};function cr(n,e){return[`You are fixing one specific SEO/AEO issue in a ${e} codebase. Make the smallest correct change; do not refactor unrelated code, do not reformat files, and match the project's existing code style.`,"",`Issue: ${n.title}`,`Details: ${n.description}`,`Recommended fix: ${n.recommendation}`,n.evidence.length>0?`Affected files/locations:
32
+ `),d=await n.analyzeJson({system:o,prompt:a,jsonSchema:wr}),m=Ne.parse({id:yr(),type:"keywords",auditId:e.id,domain:e.domain,createdAt:new Date().toISOString(),...d});if(r.volumes)for(let y of m.keywords){let p=r.volumes.get(y.keyword.toLowerCase());p!==void 0&&(y.searchVolume=p)}return m}import L from"fs";import _ from"path";import{randomUUID as br}from"crypto";var vr=new Set(["node_modules",".git",".next","dist","build","out",".vercel",".turbo","coverage",".cache","vendor","venv",".venv","__pycache__","wp-admin","wp-includes","storage","tmp","log"]),kr=new Set([".html",".htm",".jsx",".tsx",".js",".ts",".mjs",".astro",".vue",".svelte",".md",".mdx",".php",".twig",".liquid",".erb",".ejs",".hbs",".py",".rb"]),ft=500;function Ar(n,e){let t=(...i)=>i.some(s=>L.existsSync(_.join(e,s))),r=(i,s)=>{try{return L.readFileSync(_.join(e,i),"utf8").toLowerCase().includes(s)}catch{return!1}};if(t("wp-config.php","wp-content")||r("style.css","theme name:"))return"WordPress";if(t("artisan"))return"Laravel";if(t("manage.py"))return"Django";if(r("requirements.txt","flask")||r("pyproject.toml","flask"))return"Flask";if(r("requirements.txt","fastapi")||r("pyproject.toml","fastapi"))return"FastAPI";if(r("Gemfile","rails"))return"Ruby on Rails";if(t("config.rb")&&r("Gemfile","middleman"))return"Middleman";if(t("_config.yml"))return"Jekyll";if(t("config.toml","hugo.toml")&&t("content"))return"Hugo";let o={...n?.dependencies??{},...n?.devDependencies??{}};return o.next?"Next.js":o.astro?"Astro":o.nuxt?"Nuxt":o["@sveltejs/kit"]?"SvelteKit":o.gatsby?"Gatsby":o["@remix-run/react"]||o["react-router"]?"Remix/React Router":o.express||o.fastify||o.hono||o.koa?"Node.js server":o.react&&o.vite?"React (Vite SPA)":o.react?"React":o.vue?"Vue":t("composer.json")||t("index.php")?"PHP":L.existsSync(_.join(e,"index.html"))?"Static HTML":"Unknown"}function me(n){let e=_.resolve(n);if(!L.existsSync(e)||!L.statSync(e).isDirectory())throw new Error(`Not a directory: ${e}`);let t=[],r=i=>{if(t.length>=ft)return;let s;try{s=L.readdirSync(i,{withFileTypes:!0})}catch{return}for(let a of s){if(t.length>=ft)return;if(a.name.startsWith(".")&&a.name!==".well-known")continue;let d=_.join(i,a.name);if(a.isDirectory())vr.has(a.name)||r(d);else{let m=_.extname(a.name).toLowerCase();if(!kr.has(m))continue;try{if(L.statSync(d).size>4e5)continue;t.push({rel:_.relative(e,d),abs:d,ext:m,content:L.readFileSync(d,"utf8")})}catch{}}}};r(e);let o=null;try{o=JSON.parse(L.readFileSync(_.join(e,"package.json"),"utf8"))}catch{}return{root:e,framework:Ar(o,e),files:t,packageJson:o,hasPublicFile:i=>L.existsSync(_.join(e,"public",i))||L.existsSync(_.join(e,"static",i))||L.existsSync(_.join(e,i))}}var Sr=0;function j(n,e,t,r,o,i,s,a=s.length||1){return{id:`cf-${++Sr}-${n}`,ruleId:n,category:e,severity:t,title:r,description:o,recommendation:i,evidence:s.slice(0,8),occurrences:a,source:"rule"}}function xr(n){return n.files.some(e=>/^(src\/)?app\/layout\.(tsx|jsx|js|ts)$/.test(e.rel))}function he(n){let e=[],t=n.framework==="Next.js",r=t&&xr(n),o=["WordPress","Ruby on Rails","Django","Laravel"].includes(n.framework),i=n.files.filter(g=>[".jsx",".tsx",".astro",".vue",".svelte",".html",".htm",".php",".twig",".liquid",".erb",".ejs",".hbs"].includes(g.ext)),s=n.files.map(g=>g.content).join(`
33
+ `);if(!(n.hasPublicFile("robots.txt")||t&&n.files.some(g=>/^(src\/)?app\/robots\.(ts|js|tsx)$/.test(g.rel)))&&!o&&e.push(j("cb-robots-missing","technical","warning","No robots.txt in the project","The project ships no robots.txt (static or generated), so crawlers get no guidance and no sitemap pointer.",t?"Add an app/robots.ts using Next.js Metadata routes (or public/robots.txt).":"Add a robots.txt to the public/static directory.",[{location:"public/robots.txt",snippet:"not found"}])),!(n.hasPublicFile("sitemap.xml")||t&&n.files.some(g=>/^(src\/)?app\/sitemap\.(ts|js|tsx)$/.test(g.rel))||JSON.stringify(n.packageJson?.dependencies??{}).includes("next-sitemap"))&&!o&&e.push(j("cb-sitemap-missing","technical","warning","No sitemap generation","No sitemap.xml or sitemap generation code was found; new pages will be discovered slowly.",t?"Add an app/sitemap.ts Metadata route that enumerates your routes.":"Generate a sitemap.xml at build time and ship it in public/.",[{location:"public/sitemap.xml",snippet:"not found"}])),n.hasPublicFile("llms.txt")||e.push(j("cb-llms-txt","aeo","info","No llms.txt","The project doesn't publish an llms.txt \u2014 an emerging convention giving AI systems a curated map of your content.","Add public/llms.txt with a short markdown description of the site and links to key pages.",[{location:"public/llms.txt",snippet:"not found"}])),r){let g=n.files.filter(l=>/^(src\/)?app\/.*(layout|page)\.(tsx|jsx|js|ts)$/.test(l.rel));if(g.filter(l=>/export\s+(const|async function|function)\s+(metadata|generateMetadata)\b/.test(l.content)).length===0)e.push(j("cb-no-metadata-export","technical","critical","No Metadata API usage in the App Router","No layout or page exports `metadata`/`generateMetadata`, so pages ship without titles and descriptions.","Export `metadata` from app/layout.tsx (site defaults) and per-page metadata (or generateMetadata) for every route.",[{location:"app/layout.tsx"}]));else{let u=g.filter(h=>/page\.(tsx|jsx|js|ts)$/.test(h.rel)).filter(h=>!/export\s+(const|async function|function)\s+(metadata|generateMetadata)\b/.test(h.content));u.length>0&&e.push(j("cb-pages-without-metadata","technical","warning","Routes without their own metadata",`${u.length} page(s) rely on inherited metadata, so several routes share the same title/description.`,"Export route-specific `metadata` (title, description, openGraph) from each page.tsx.",u.map(h=>({location:h.rel})),u.length))}}else{let g=n.files.filter(h=>[".html",".htm"].includes(h.ext)),c=g.filter(h=>!/<title[\s>]/i.test(h.content));c.length>0&&e.push(j("cb-html-no-title","technical","critical","HTML files without <title>",`${c.length} HTML file(s) have no title tag.`,"Add a unique, descriptive <title> to every HTML document.",c.map(h=>({location:h.rel})),c.length));let l=g.filter(h=>!/name=["']description["']/i.test(h.content));l.length>0&&e.push(j("cb-html-no-description","technical","warning","HTML files without a meta description",`${l.length} HTML file(s) lack a meta description.`,'Add <meta name="description" content="..."> to every HTML document.',l.map(h=>({location:h.rel})),l.length));let u=g.filter(h=>!/name=["']viewport["']/i.test(h.content));u.length>0&&e.push(j("cb-html-no-viewport","technical","critical","HTML files without a viewport meta tag",`${u.length} HTML file(s) lack the responsive viewport tag; mobile-first indexing will penalize them.`,'Add <meta name="viewport" content="width=device-width, initial-scale=1">.',u.map(h=>({location:h.rel})),u.length))}let m=[],y=0;for(let g of i){let l=(g.content.match(/<(?:img|Image)\b[^>]*>/g)??[]).filter(u=>!/\balt\s*=/.test(u));l.length>0&&(y+=l.length,m.push({location:g.rel,snippet:l[0].slice(0,120)}))}if(y>0&&e.push(j("cb-img-no-alt","content","warning","Image elements without alt text",`${y} <img>/<Image> element(s) across ${m.length} file(s) have no alt attribute.`,'Add descriptive alt text to meaningful images; use alt="" for decorative ones.',m,y)),t){let g=i.filter(c=>/<img\b/.test(c.content));g.length>0&&e.push(j("cb-raw-img-next","technical","info","Raw <img> instead of next/image",`${g.length} file(s) use raw <img> tags; next/image provides automatic optimization, lazy loading and correct sizing.`,"Replace raw <img> with the next/image <Image> component where practical.",g.map(c=>({location:c.rel})),g.length))}return/application\/ld\+json|schema\.org/i.test(s)||e.push(j("cb-no-jsonld","structured-data","warning","No JSON-LD structured data in the codebase","No file references schema.org or emits application/ld+json. Structured data is a primary input for rich results and AI answer attribution.","Add Organization + WebSite JSON-LD to the root layout, and page-type schema (Article, FAQPage\u2026) where relevant.",[{location:n.root}])),/og:|openGraph/i.test(s)||e.push(j("cb-no-og","structured-data","warning","No Open Graph tags","No Open Graph configuration was found, so link shares render without preview cards.",r?"Add an `openGraph` section to your metadata exports (title, description, images).":"Add og:title, og:description and og:image meta tags to your page heads.",[{location:n.root}])),/canonical|alternates/i.test(s)||e.push(j("cb-no-canonical","technical","info","No canonical URL handling","No canonical link handling was found; duplicate URL variants may compete with each other.",r?"Set `metadataBase` and `alternates.canonical` in your metadata exports.":'Emit <link rel="canonical"> on each page.',[{location:n.root}])),e}function fe(n,e){return De.parse({id:br(),type:"codebase",path:n.root,framework:n.framework,createdAt:new Date().toISOString(),filesScanned:n.files.length,scorecard:ue(e),findings:e})}import Pr from"fs";import $r from"path";import{execFile as Rr}from"child_process";import{promisify as Cr}from"util";var Ir=Cr(Rr);async function G(n,...e){let{stdout:t}=await Ir("git",e,{cwd:n,maxBuffer:10485760});return t}async function wt(n){try{return await G(n,"rev-parse","--is-inside-work-tree"),!0}catch{return!1}}async function bt(n){return(await G(n,"status","--porcelain")).trim()===""}async function Ce(n){return(await G(n,"status","--porcelain")).split(`
34
+ `).map(t=>t.trim()).filter(Boolean).map(t=>t.replace(/^[A-Z?!]{1,2}\s+/,"").replace(/^"|"$/g,""))}async function Er(n,e){return e.length===0?"":(await G(n,"add","-N","--",...e).catch(()=>{}),G(n,"diff","--",...e))}async function vt(n,e){if(e.length===0)return;await G(n,"reset","--",...e).catch(()=>{}),await G(n,"checkout","--",...e).catch(()=>{});let t=await Ce(n),r=e.filter(o=>t.includes(o));r.length>0&&await G(n,"clean","-f","--",...r).catch(()=>{})}var yt={"cb-llms-txt":'llms.txt format (llmstxt.org convention): a markdown file at the site root/public dir starting with "# <Site name>", then a one-paragraph blockquote summary of what the site/product is, then "## Docs"/"## Pages" sections containing "- [Page title](absolute-url): one-line description" entries for the most important pages. Derive names, URLs and descriptions from the actual project \u2014 do not invent pages.',"cb-robots-missing":"robots.txt: allow all well-behaved crawlers by default, include AI answer-engine crawlers (do NOT disallow GPTBot/ClaudeBot/PerplexityBot unless the project clearly intends to block them), and end with a Sitemap: line pointing at the site's sitemap URL.","cb-sitemap-missing":"Sitemap: enumerate the project's real routes (inspect the routing/pages structure) with absolute URLs. For frameworks with dynamic routes, generate from the route source rather than hardcoding a stale list.","cb-no-jsonld":"JSON-LD: add Organization + WebSite schema in the site-wide layout/head (name, url, logo, sameAs from real social links found in the project). Use https://schema.org @context, absolute URLs, and no placeholder text.","cb-no-og":"Open Graph: og:title, og:description, og:type, og:url, og:image (pick a real existing image asset from the project \u2014 do not reference files that don't exist)."};function jr(n,e){return[`You are fixing one specific SEO/AEO issue in a ${e} codebase. Make the smallest correct change; do not refactor unrelated code, do not reformat files, and match the project's existing code style.`,"",`Issue: ${n.title}`,`Details: ${n.description}`,`Recommended fix: ${n.recommendation}`,n.evidence.length>0?`Affected files/locations:
34
35
  ${n.evidence.map(t=>`- ${t.location}${t.snippet?` (${t.snippet})`:""}`).join(`
35
- `)}`:"",st[n.ruleId]?`Format guidance: ${st[n.ruleId]}`:"","","Apply the fix now by editing the files. Rules:","- Read each file before editing it; never assume its contents. When content is needed (titles, descriptions, alt text, structured data), derive it from the actual project content \u2014 read neighboring files to understand what the site is about; do not invent facts about the business.","- Verify before finishing: after editing, re-read the changed files and confirm the fix is complete, syntactically valid, and consistent with the project's conventions. A fix is done only when verified, never based on intent.","- If part of the fix cannot be completed safely (missing information, ambiguity), do what is safe and explicitly state what was NOT done and why \u2014 do not silently claim full completion.","Finish with a one-paragraph summary of exactly what you changed and what, if anything, remains."].filter(Boolean).join(`
36
- `)}function lr(n,e){let t=[];for(let r of e){let o;try{o=nr.readFileSync(rr.join(n,r),"utf8")}catch{continue}let i=o.match(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)??[];for(let s of i){let a=s.replace(/^<script[^>]*>/i,"").replace(/<\/script>$/i,"").trim();if(!/[{<][{%?]|JSON\.stringify|dangerouslySetInnerHTML/.test(a))try{JSON.parse(a)}catch(d){t.push(`${r}: JSON-LD block does not parse (${d instanceof Error?d.message.slice(0,80):"invalid"}) \u2014 search engines will silently ignore it.`)}}}return t}async function dt(n,e,t,r,o){let i=await ke(e),s=await n.runAgent({cwd:e,prompt:cr(t,r),onEvent:o}),d=(await ke(e)).filter(u=>!i.includes(u)),m=await ar(e,d),w=lr(e,d);return{findingId:t.id,agentSummary:s,changes:d.map(u=>({file:u,diff:m.split(/^(?=diff --git )/m).find(g=>g.includes(`b/${u}`))??""})),warnings:w.length>0?w:void 0}}import{randomUUID as dr}from"crypto";var pr={type:"object",additionalProperties:!1,required:["prompts"],properties:{prompts:{type:"array",minItems:6,maxItems:10,items:{type:"string"},description:"Realistic questions actual users would ask an AI assistant in this site's space. Never mention the site by name \u2014 these must be neutral discovery questions."}}},ur={type:"object",additionalProperties:!1,required:["results"],properties:{results:{type:"array",items:{type:"object",additionalProperties:!1,required:["prompt","answer","recommendedBrands"],properties:{prompt:{type:"string"},answer:{type:"string",description:"Answer the question exactly as you would for a real user asking you this \u2014 recommend whatever you would genuinely recommend. 3-5 sentences."},recommendedBrands:{type:"array",items:{type:"string"},description:"Every brand, product, site or organization your answer recommended or cited, in order of prominence."}}}}}},gr={type:"object",additionalProperties:!1,required:["classifications"],properties:{classifications:{type:"array",items:{type:"object",additionalProperties:!1,required:["index","presence","framing","recommendedAgainst"],properties:{index:{type:"integer",description:"Index of the answer being classified"},presence:{type:"string",enum:["recommended","mentioned","absent"],description:"recommended = the answer puts the target on the shortlist it gives the buyer; mentioned = named but not recommended; absent = not in the answer"},framing:{type:"string",enum:["favorable","neutral","hedged","negative","not-mentioned"],description:"How the target is framed where it appears (hedged = mentioned with caveats/reservations)"},recommendedAgainst:{type:"boolean",description:"True if the answer actively steers the buyer away from the target (criticizes it or explicitly prefers alternatives over it)"}}}}}};function mr(n){let e=n.replace(/^https?:\/\//,"").replace(/^www\./,""),t=e.split(".")[0];return[e.toLowerCase(),t.toLowerCase()]}function pt(n,e,t){let r=mr(n),o=s=>r.some(a=>s.toLowerCase().includes(a)),i=t.findIndex(o);return i===0?"recommended":i>0||o(e)?"mentioned":"absent"}function hr(n){if(n.length===0)return 0;let e=n.reduce((t,r)=>r.recommendedAgainst?t:r.presence==="recommended"||r.presence==="primary"?t+1:r.presence==="mentioned"?t+.4:t,0);return Math.round(e/n.length*100)}var fr=[{engine:"Google AI Overviews",weights:{"no-structured-data":25,"no-answer-schema":15,"client-rendered-content":30,"no-question-headings":10,"poor-answer-blocks":10,"missing-entity-schema":15,"ai-bots-blocked":20,"noindex-pages":10}},{engine:"ChatGPT",weights:{"stale-content":20,"no-freshness-signals":15,"no-author-signals":20,"ai-bots-blocked":40,"client-rendered-content":25,"missing-entity-schema":10}},{engine:"Perplexity",weights:{"no-answer-schema":20,"poor-answer-blocks":25,"ai-bots-blocked":40,"client-rendered-content":25,"no-question-headings":10}},{engine:"Microsoft Copilot",weights:{"sitemap-missing":20,"slow-pages":20,"client-rendered-content":25,"robots-missing":10,"missing-title":15}},{engine:"Claude",weights:{"no-author-signals":15,"thin-content":20,"ai-bots-blocked":40,"client-rendered-content":25,"stale-content":10}}];function yr(n){let e=new Map(n.map(t=>[t.ruleId,t]));return fr.map(({engine:t,weights:r})=>{let o=0,i=[];for(let[s,a]of Object.entries(r)){let d=e.get(s);d&&(o+=a,i.push(d.title))}return{engine:t,score:Math.max(0,100-o),notes:i.slice(0,4)}})}async function ut(n,e,t,r){let o=r??(()=>{}),i=e.domain,s=t.filter(Boolean);if(s.length===0){o("Generating realistic buyer prompts for this niche",.15);let f=e.pages.map($=>({url:$.url,title:$.title,description:$.metaDescription})).slice(0,15);s=(await n.analyzeJson({system:"You design AI-visibility test prompts. Given a crawled site, infer its niche and audience, then write the neutral questions real users would ask an AI assistant when they need what this site offers.",prompt:`Site pages:
36
+ `)}`:"",yt[n.ruleId]?`Format guidance: ${yt[n.ruleId]}`:"","","Apply the fix now by editing the files. Rules:","- Read each file before editing it; never assume its contents. When content is needed (titles, descriptions, alt text, structured data), derive it from the actual project content \u2014 read neighboring files to understand what the site is about; do not invent facts about the business.","- Verify before finishing: after editing, re-read the changed files and confirm the fix is complete, syntactically valid, and consistent with the project's conventions. A fix is done only when verified, never based on intent.","- If part of the fix cannot be completed safely (missing information, ambiguity), do what is safe and explicitly state what was NOT done and why \u2014 do not silently claim full completion.","Finish with a one-paragraph summary of exactly what you changed and what, if anything, remains."].filter(Boolean).join(`
37
+ `)}function Or(n,e){let t=[];for(let r of e){let o;try{o=Pr.readFileSync($r.join(n,r),"utf8")}catch{continue}let i=o.match(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)??[];for(let s of i){let a=s.replace(/^<script[^>]*>/i,"").replace(/<\/script>$/i,"").trim();if(!/[{<][{%?]|JSON\.stringify|dangerouslySetInnerHTML/.test(a))try{JSON.parse(a)}catch(d){t.push(`${r}: JSON-LD block does not parse (${d instanceof Error?d.message.slice(0,80):"invalid"}) \u2014 search engines will silently ignore it.`)}}}return t}async function kt(n,e,t,r,o){let i=await Ce(e),s=await n.runAgent({cwd:e,prompt:jr(t,r),onEvent:o}),d=(await Ce(e)).filter(p=>!i.includes(p)),m=await Er(e,d),y=Or(e,d);return{findingId:t.id,agentSummary:s,changes:d.map(p=>({file:p,diff:m.split(/^(?=diff --git )/m).find(g=>g.includes(`b/${p}`))??""})),warnings:y.length>0?y:void 0}}import{randomUUID as Lr}from"crypto";var Tr={type:"object",additionalProperties:!1,required:["prompts"],properties:{prompts:{type:"array",minItems:6,maxItems:10,items:{type:"string"},description:"Realistic questions actual users would ask an AI assistant in this site's space. Never mention the site by name \u2014 these must be neutral discovery questions."}}},_r={type:"object",additionalProperties:!1,required:["results"],properties:{results:{type:"array",items:{type:"object",additionalProperties:!1,required:["prompt","answer","recommendedBrands"],properties:{prompt:{type:"string"},answer:{type:"string",description:"Answer the question exactly as you would for a real user asking you this \u2014 recommend whatever you would genuinely recommend. 3-5 sentences."},recommendedBrands:{type:"array",items:{type:"string"},description:"Every brand, product, site or organization your answer recommended or cited, in order of prominence."}}}}}},Mr={type:"object",additionalProperties:!1,required:["classifications"],properties:{classifications:{type:"array",items:{type:"object",additionalProperties:!1,required:["index","presence","framing","recommendedAgainst"],properties:{index:{type:"integer",description:"Index of the answer being classified"},presence:{type:"string",enum:["recommended","mentioned","absent"],description:"recommended = the answer puts the target on the shortlist it gives the buyer; mentioned = named but not recommended; absent = not in the answer"},framing:{type:"string",enum:["favorable","neutral","hedged","negative","not-mentioned"],description:"How the target is framed where it appears (hedged = mentioned with caveats/reservations)"},recommendedAgainst:{type:"boolean",description:"True if the answer actively steers the buyer away from the target (criticizes it or explicitly prefers alternatives over it)"}}}}}};function Nr(n){let e=n.replace(/^https?:\/\//,"").replace(/^www\./,""),t=e.split(".")[0];return[e.toLowerCase(),t.toLowerCase()]}function At(n,e,t){let r=Nr(n),o=s=>r.some(a=>s.toLowerCase().includes(a)),i=t.findIndex(o);return i===0?"recommended":i>0||o(e)?"mentioned":"absent"}function Dr(n){if(n.length===0)return 0;let e=n.reduce((t,r)=>r.recommendedAgainst?t:r.presence==="recommended"||r.presence==="primary"?t+1:r.presence==="mentioned"?t+.4:t,0);return Math.round(e/n.length*100)}var Fr=[{engine:"Google AI Overviews",weights:{"no-structured-data":25,"no-answer-schema":15,"client-rendered-content":30,"no-question-headings":10,"poor-answer-blocks":10,"missing-entity-schema":15,"ai-bots-blocked":20,"noindex-pages":10}},{engine:"ChatGPT",weights:{"stale-content":20,"no-freshness-signals":15,"no-author-signals":20,"ai-bots-blocked":40,"client-rendered-content":25,"missing-entity-schema":10}},{engine:"Perplexity",weights:{"no-answer-schema":20,"poor-answer-blocks":25,"ai-bots-blocked":40,"client-rendered-content":25,"no-question-headings":10}},{engine:"Microsoft Copilot",weights:{"sitemap-missing":20,"slow-pages":20,"client-rendered-content":25,"robots-missing":10,"missing-title":15}},{engine:"Claude",weights:{"no-author-signals":15,"thin-content":20,"ai-bots-blocked":40,"client-rendered-content":25,"stale-content":10}}];function Gr(n){let e=new Map(n.map(t=>[t.ruleId,t]));return Fr.map(({engine:t,weights:r})=>{let o=0,i=[];for(let[s,a]of Object.entries(r)){let d=e.get(s);d&&(o+=a,i.push(d.title))}return{engine:t,score:Math.max(0,100-o),notes:i.slice(0,4)}})}async function St(n,e,t,r){let o=r??(()=>{}),i=e.domain,s=t.filter(Boolean);if(s.length===0){o("Generating realistic buyer prompts for this niche",.15);let f=e.pages.map($=>({url:$.url,title:$.title,description:$.metaDescription})).slice(0,15);s=(await n.analyzeJson({system:"You design AI-visibility test prompts. Given a crawled site, infer its niche and audience, then write the neutral questions real users would ask an AI assistant when they need what this site offers.",prompt:`Site pages:
37
38
  ${JSON.stringify(f,null,1)}
38
39
 
39
- Write 8 such questions. NEVER name the site or brand \u2014 the point is to test whether it comes up on its own.`,jsonSchema:pr,tier:"light"})).prompts}o(`Asking the model ${s.length} discovery questions`,.4);let a=await n.analyzeJson({system:"Answer each question exactly as you would for a real user who asked you it directly \u2014 give your genuine recommendations from your own knowledge. Do NOT try to guess what this test wants; just answer naturally and honestly, then list the brands/sites your answer recommended.",prompt:`Questions:
40
- ${s.map((f,A)=>`${A+1}. ${f}`).join(`
41
- `)}`,jsonSchema:ur});o("Classifying visibility ladder and framing",.7);let d=[];try{d=(await n.analyzeJson({system:"You classify how a target brand appears in AI-generated answers. Judge only from the answer text given \u2014 do not use outside knowledge about the brand. Be strict: 'recommended' requires the answer to actually put the target on the shortlist it gives the user, not merely name it.",prompt:`Target brand/site: ${i}
40
+ Write 8 such questions. NEVER name the site or brand \u2014 the point is to test whether it comes up on its own.`,jsonSchema:Tr,tier:"light"})).prompts}o(`Asking the model ${s.length} discovery questions`,.4);let a=await n.analyzeJson({system:"Answer each question exactly as you would for a real user who asked you it directly \u2014 give your genuine recommendations from your own knowledge. Do NOT try to guess what this test wants; just answer naturally and honestly, then list the brands/sites your answer recommended.",prompt:`Questions:
41
+ ${s.map((f,S)=>`${S+1}. ${f}`).join(`
42
+ `)}`,jsonSchema:_r});o("Classifying visibility ladder and framing",.7);let d=[];try{d=(await n.analyzeJson({system:"You classify how a target brand appears in AI-generated answers. Judge only from the answer text given \u2014 do not use outside knowledge about the brand. Be strict: 'recommended' requires the answer to actually put the target on the shortlist it gives the user, not merely name it.",prompt:`Target brand/site: ${i}
42
43
 
43
44
  Answers (JSON):
44
- ${JSON.stringify(a.results.map((A,$)=>({index:$,question:A.prompt,answer:A.answer,brandsRecommended:A.recommendedBrands})),null,1)}
45
+ ${JSON.stringify(a.results.map((S,$)=>({index:$,question:S.prompt,answer:S.answer,brandsRecommended:S.recommendedBrands})),null,1)}
45
46
 
46
- Classify every answer by index.`,jsonSchema:gr,tier:"light"})).classifications}catch{}o("Scoring visibility",.85);let m=a.results.map((f,A)=>{let $=pt(i,f.answer,f.recommendedBrands),v=d.find(U=>U.index===A),j=$==="absent"?"absent":v?.presence??$;return{prompt:f.prompt,presence:j,framing:j==="absent"||!v||v.framing==="not-mentioned"?void 0:v.framing,recommendedAgainst:v?.recommendedAgainst||void 0,answerExcerpt:f.answer.slice(0,400),competitors:f.recommendedBrands.filter(U=>pt(i,"",[U])==="absent").slice(0,6)}}),w=new Map;for(let f of m)for(let A of f.competitors)w.set(A,(w.get(A)??0)+1);let u=[...w.entries()].map(([f,A])=>({name:f,count:A})).sort((f,A)=>A.count-f.count).slice(0,10),g=hr(m),l=m.filter(f=>f.presence==="recommended"||f.presence==="primary").length,c=m.filter(f=>f.presence==="mentioned").length,p=m.filter(f=>f.recommendedAgainst).length,h=m.length-l-c,b=`Ladder: recommended in ${l}/${m.length}, mentioned-only in ${c}, absent in ${h}${p>0?`, actively recommended AGAINST in ${p}`:""}.`,P=p>0?"Being named but steered away from is the most urgent gap \u2014 investigate the negative framing and address it at the source (reviews, comparisons, docs).":l===0&&c>0?"The model knows the brand but doesn't put it on shortlists \u2014 a mention-to-recommendation gap. That is governed by aggregate web consensus (reviews, forums, comparisons), not on-page SEO.":g>=60?"The model already treats this site as a go-to source in its niche.":g>=25?"Partial visibility \u2014 competitors are recommended more often; strengthen topical authority and entity signals.":"Largely invisible to AI answers today; the competitors listed own this space in the model's knowledge.";return je.parse({id:dr(),type:"ai-visibility",domain:i,createdAt:new Date().toISOString(),visibilityScore:g,prompts:m,competitorLeaderboard:u,engineReadiness:yr(e.findings),summary:`${b} ${P}`})}var wr="https://www.googleapis.com/auth/webmasters.readonly",br="https://accounts.google.com/o/oauth2/v2/auth",vr="https://oauth2.googleapis.com/token";function gt(n,e){let t=new URL(br);return t.searchParams.set("client_id",n),t.searchParams.set("redirect_uri",e),t.searchParams.set("response_type","code"),t.searchParams.set("scope",wr),t.searchParams.set("access_type","offline"),t.searchParams.set("prompt","consent"),t.toString()}async function mt(n){let e=await fetch(vr,{method:"POST",headers:{"content-type":"application/x-www-form-urlencoded"},body:new URLSearchParams(n).toString()}),t=await e.json();if(!e.ok||t.error)throw new Error(`Google OAuth: ${t.error??e.status} ${t.error_description??""}`.trim());return t}async function ht(n,e,t,r){let o=await mt({client_id:n,client_secret:e,code:t,grant_type:"authorization_code",redirect_uri:r});if(!o.refresh_token)throw new Error("Google did not return a refresh token \u2014 remove the app's prior grant at myaccount.google.com/permissions and reconnect.");return{refreshToken:o.refresh_token}}async function ft(n,e,t){let r=await mt({client_id:n,client_secret:e,refresh_token:t,grant_type:"refresh_token"});if(!r.access_token)throw new Error("Google OAuth refresh returned no access token");return r.access_token}async function yt(n,e,t){let r=await fetch(`https://www.googleapis.com/webmasters/v3${e}`,{...t,headers:{authorization:`Bearer ${n}`,"content-type":"application/json",...t?.headers}});if(!r.ok)throw new Error(`Search Console API ${r.status}: ${(await r.text()).slice(0,200)}`);return await r.json()}async function xe(n){return(await yt(n,"/sites")).siteEntry??[]}function Ae(n,e){let t=e.replace(/^https?:\/\//,"").replace(/^www\./,"").replace(/\/$/,"");for(let r of n){let o=r.siteUrl;if(o===`sc-domain:${t}`||o.replace(/^https?:\/\//,"").replace(/^www\./,"").replace(/\/$/,"")===t)return o}return null}async function Se(n,e,t=90,r=100){let o=new Date,i=new Date(o.getTime()-t*864e5),s=d=>d.toISOString().slice(0,10);return((await yt(n,`/sites/${encodeURIComponent(e)}/searchAnalytics/query`,{method:"POST",body:JSON.stringify({startDate:s(i),endDate:s(o),dimensions:["query"],rowLimit:r})})).rows??[]).map(d=>({query:d.keys[0],clicks:d.clicks,impressions:d.impressions,ctr:d.ctr,position:d.position}))}var wt=["product","category","comparison","listicle","guide","blog","tool","landing","docs","local","other"],kr={type:"object",additionalProperties:!1,required:["results"],properties:{results:{type:"array",items:{type:"object",additionalProperties:!1,required:["keyword","consensusType","sitePageType","competitorsSeen","advice"],properties:{keyword:{type:"string"},consensusType:{type:"string",enum:[...wt],description:"The dominant page TYPE among the search results you actually saw for this query"},sitePageType:{type:"string",enum:[...wt],description:"The page type of the site's best page for this keyword (from the crawled pages list)"},competitorsSeen:{type:"array",maxItems:5,items:{type:"string"},description:"Domains you saw ranking for this query"},advice:{type:"string",description:"One specific sentence: what page type/change would fit this SERP"}}}}}};function xr(n,e){if(n===e)return"none";let t=new Set(["blog","guide","docs"]);return t.has(n)&&e==="product"?"critical":t.has(n)&&(e==="comparison"||e==="listicle")||n==="product"&&t.has(e)||n==="landing"&&e==="tool"?"high":t.has(n)&&t.has(e)?"none":"medium"}var Ar=3*864e5;async function bt(n,e,t,r=5,o,i){let s=t.keywords.slice(0,r);if(s.length===0)return;let a=u=>`serpfit:${e.domain}:${u.toLowerCase()}`,d=[];for(let u of s){let g=i?.kvGet(a(u.keyword),Ar);g?u.serp=g:d.push(u)}if(d.length===0){o?.("SERP fit served from cache");return}let m=e.pages.map(u=>({url:u.url,title:u.title})).slice(0,30);o?.(`Web-searching ${d.length} keywords for SERP fit`);let w=await n.analyzeJson({system:"You are a SERP analyst. For EACH keyword given, actually search the web for that exact query and observe what ranks. Classify the dominant page type among the results you saw \u2014 never guess from the keyword alone; if you could not search, say consensusType 'other'. Then classify the site's own best page for that keyword (from the crawled list) by the same taxonomy.",prompt:[`Site: ${e.domain}`,`Crawled pages:
47
- ${JSON.stringify(m,null,1)}`,`Keywords to analyze: ${d.map(u=>`"${u.keyword}"`).join(", ")}`,"For each: search the query, report the dominant ranking page type, the competitors you saw, the site's best-page type, and one sentence of fit advice."].join(`
47
+ Classify every answer by index.`,jsonSchema:Mr,tier:"light"})).classifications}catch{}o("Scoring visibility",.85);let m=a.results.map((f,S)=>{let $=At(i,f.answer,f.recommendedBrands),x=d.find(U=>U.index===S),E=$==="absent"?"absent":x?.presence??$;return{prompt:f.prompt,presence:E,framing:E==="absent"||!x||x.framing==="not-mentioned"?void 0:x.framing,recommendedAgainst:x?.recommendedAgainst||void 0,answerExcerpt:f.answer.slice(0,400),competitors:f.recommendedBrands.filter(U=>At(i,"",[U])==="absent").slice(0,6)}}),y=new Map;for(let f of m)for(let S of f.competitors)y.set(S,(y.get(S)??0)+1);let p=[...y.entries()].map(([f,S])=>({name:f,count:S})).sort((f,S)=>S.count-f.count).slice(0,10),g=Dr(m),c=m.filter(f=>f.presence==="recommended"||f.presence==="primary").length,l=m.filter(f=>f.presence==="mentioned").length,u=m.filter(f=>f.recommendedAgainst).length,h=m.length-c-l,b=`Ladder: recommended in ${c}/${m.length}, mentioned-only in ${l}, absent in ${h}${u>0?`, actively recommended AGAINST in ${u}`:""}.`,P=u>0?"Being named but steered away from is the most urgent gap \u2014 investigate the negative framing and address it at the source (reviews, comparisons, docs).":c===0&&l>0?"The model knows the brand but doesn't put it on shortlists \u2014 a mention-to-recommendation gap. That is governed by aggregate web consensus (reviews, forums, comparisons), not on-page SEO.":g>=60?"The model already treats this site as a go-to source in its niche.":g>=25?"Partial visibility \u2014 competitors are recommended more often; strengthen topical authority and entity signals.":"Largely invisible to AI answers today; the competitors listed own this space in the model's knowledge.";return Fe.parse({id:Lr(),type:"ai-visibility",domain:i,createdAt:new Date().toISOString(),visibilityScore:g,prompts:m,competitorLeaderboard:p,engineReadiness:Gr(e.findings),summary:`${b} ${P}`})}var qr="https://www.googleapis.com/auth/webmasters.readonly",Jr="https://accounts.google.com/o/oauth2/v2/auth",Hr="https://oauth2.googleapis.com/token";function xt(n,e){let t=new URL(Jr);return t.searchParams.set("client_id",n),t.searchParams.set("redirect_uri",e),t.searchParams.set("response_type","code"),t.searchParams.set("scope",qr),t.searchParams.set("access_type","offline"),t.searchParams.set("prompt","consent"),t.toString()}async function Pt(n){let e=await fetch(Hr,{method:"POST",headers:{"content-type":"application/x-www-form-urlencoded"},body:new URLSearchParams(n).toString()}),t=await e.json();if(!e.ok||t.error)throw new Error(`Google OAuth: ${t.error??e.status} ${t.error_description??""}`.trim());return t}async function $t(n,e,t,r){let o=await Pt({client_id:n,client_secret:e,code:t,grant_type:"authorization_code",redirect_uri:r});if(!o.refresh_token)throw new Error("Google did not return a refresh token \u2014 remove the app's prior grant at myaccount.google.com/permissions and reconnect.");return{refreshToken:o.refresh_token}}async function Rt(n,e,t){let r=await Pt({client_id:n,client_secret:e,refresh_token:t,grant_type:"refresh_token"});if(!r.access_token)throw new Error("Google OAuth refresh returned no access token");return r.access_token}async function Ct(n,e,t){let r=await fetch(`https://www.googleapis.com/webmasters/v3${e}`,{...t,headers:{authorization:`Bearer ${n}`,"content-type":"application/json",...t?.headers}});if(!r.ok)throw new Error(`Search Console API ${r.status}: ${(await r.text()).slice(0,200)}`);return await r.json()}async function Ie(n){return(await Ct(n,"/sites")).siteEntry??[]}function Ee(n,e){let t=e.replace(/^https?:\/\//,"").replace(/^www\./,"").replace(/\/$/,"");for(let r of n){let o=r.siteUrl;if(o===`sc-domain:${t}`||o.replace(/^https?:\/\//,"").replace(/^www\./,"").replace(/\/$/,"")===t)return o}return null}async function je(n,e,t=90,r=100){let o=new Date,i=new Date(o.getTime()-t*864e5),s=d=>d.toISOString().slice(0,10);return((await Ct(n,`/sites/${encodeURIComponent(e)}/searchAnalytics/query`,{method:"POST",body:JSON.stringify({startDate:s(i),endDate:s(o),dimensions:["query"],rowLimit:r})})).rows??[]).map(d=>({query:d.keys[0],clicks:d.clicks,impressions:d.impressions,ctr:d.ctr,position:d.position}))}var It=["product","category","comparison","listicle","guide","blog","tool","landing","docs","local","other"],Ur={type:"object",additionalProperties:!1,required:["results"],properties:{results:{type:"array",items:{type:"object",additionalProperties:!1,required:["keyword","consensusType","sitePageType","competitorsSeen","advice"],properties:{keyword:{type:"string"},consensusType:{type:"string",enum:[...It],description:"The dominant page TYPE among the search results you actually saw for this query"},sitePageType:{type:"string",enum:[...It],description:"The page type of the site's best page for this keyword (from the crawled pages list)"},competitorsSeen:{type:"array",maxItems:5,items:{type:"string"},description:"Domains you saw ranking for this query"},advice:{type:"string",description:"One specific sentence: what page type/change would fit this SERP"}}}}}};function Br(n,e){if(n===e)return"none";let t=new Set(["blog","guide","docs"]);return t.has(n)&&e==="product"?"critical":t.has(n)&&(e==="comparison"||e==="listicle")||n==="product"&&t.has(e)||n==="landing"&&e==="tool"?"high":t.has(n)&&t.has(e)?"none":"medium"}var Kr=3*864e5;async function Et(n,e,t,r=5,o,i){let s=t.keywords.slice(0,r);if(s.length===0)return;let a=p=>`serpfit:${e.domain}:${p.toLowerCase()}`,d=[];for(let p of s){let g=i?.kvGet(a(p.keyword),Kr);g?p.serp=g:d.push(p)}if(d.length===0){o?.("SERP fit served from cache");return}let m=e.pages.map(p=>({url:p.url,title:p.title})).slice(0,30);o?.(`Web-searching ${d.length} keywords for SERP fit`);let y=await n.analyzeJson({system:"You are a SERP analyst. For EACH keyword given, actually search the web for that exact query and observe what ranks. Classify the dominant page type among the results you saw \u2014 never guess from the keyword alone; if you could not search, say consensusType 'other'. Then classify the site's own best page for that keyword (from the crawled list) by the same taxonomy.",prompt:[`Site: ${e.domain}`,`Crawled pages:
48
+ ${JSON.stringify(m,null,1)}`,`Keywords to analyze: ${d.map(p=>`"${p.keyword}"`).join(", ")}`,"For each: search the query, report the dominant ranking page type, the competitors you saw, the site's best-page type, and one sentence of fit advice."].join(`
48
49
 
49
- `),jsonSchema:kr,webSearch:!0,tier:"light"});for(let u of w.results){let g=t.keywords.find(l=>l.keyword.toLowerCase()===u.keyword.toLowerCase());!g||u.consensusType==="other"||(g.serp={consensusType:u.consensusType,sitePageType:u.sitePageType,mismatch:xr(u.sitePageType,u.consensusType),competitorsSeen:u.competitorsSeen.slice(0,5),advice:u.advice},i?.kvSet(a(g.keyword),g.serp))}}import{randomUUID as Sr}from"crypto";var Pr={type:"object",additionalProperties:!1,required:["searchIntent","recommendedPageType","titleSuggestion","metaDescription","informationGain","outline","faq","internalLinks","competitors"],properties:{searchIntent:{type:"string",description:"What the searcher actually wants, in one sentence"},recommendedPageType:{type:"string",description:"The page type that fits this SERP (guide, comparison, product, tool\u2026)"},titleSuggestion:{type:"string",description:"50-60 characters, primary keyword front-loaded"},metaDescription:{type:"string",description:"130-150 characters, no quote characters"},informationGain:{type:"string",description:"NON-NEGOTIABLE: exactly what NEW value this content adds that NO current ranking page provides. Never 'more detail' or 'better formatting' \u2014 a specific angle, data set, tool, or perspective."},outline:{type:"array",maxItems:10,items:{type:"object",additionalProperties:!1,required:["heading","keyPoints","targetWords"],properties:{heading:{type:"string",description:"H2/H3 phrased the way searchers phrase queries where natural"},keyPoints:{type:"array",maxItems:5,items:{type:"string"}},targetWords:{type:"integer",description:"Target words for the section; lead sections should be 40-60 word direct answers"}}}},faq:{type:"array",maxItems:6,items:{type:"object",additionalProperties:!1,required:["question","answerAngle"],properties:{question:{type:"string"},answerAngle:{type:"string"}}}},internalLinks:{type:"array",maxItems:8,items:{type:"string"},description:"URLs from the crawled-pages list (EXACT URLs from that list only) that should link to or from this new content"},competitors:{type:"array",maxItems:5,items:{type:"object",additionalProperties:!1,required:["domain","scores","strength","weakness"],properties:{domain:{type:"string",description:"A domain you actually saw ranking for this query"},scores:{type:"object",additionalProperties:!1,required:["depth","formatting","seo","ux"],properties:{depth:{type:"integer",minimum:1,maximum:10},formatting:{type:"integer",minimum:1,maximum:10},seo:{type:"integer",minimum:1,maximum:10},ux:{type:"integer",minimum:1,maximum:10}}},strength:{type:"string"},weakness:{type:"string"}}}}}};function $r(n,e){switch(n){case"vs-competitor":return`PAGE FORMAT: "[this site's product] vs ${e??"[competitor]"}" comparison page. Structure requirements: a scannable comparison table early; an honest "when ${e??"the competitor"} is the better choice" section (fairness earns trust AND AI citations \u2014 one-sided comparisons get ignored); a migration/switching section; feature-by-feature depth on the 3-4 axes buyers actually decide on (research what reviewers say). The outline MUST include these sections.`;case"alternatives":return`PAGE FORMAT: "${e??"[competitor]"} alternatives" page. Search the web for what users complain about with ${e??"the competitor"} (reviews, forums) \u2014 the page must lead with those real switching triggers, list 4-6 genuine alternatives INCLUDING this site's product honestly positioned (not always #1 on every axis), with a comparison table and per-alternative "best for" verdicts.`;case"best-roundup":return`PAGE FORMAT: "best [category]" roundup that includes this site's own product. CRITICAL: self-promotional listicles are excluded from AI answers ~69% of the time when they read as ads. The brief must define objective evaluation criteria stated on the page, honest per-product strengths/weaknesses including this site's product, and at least one scenario where a competitor wins. That honesty is the mechanism that gets the page cited.`;default:return""}}function Rr(n,e){let t=new Set(e.pages.map(r=>r.url.replace(/\/$/,"")));return n.filter(r=>t.has(r.replace(/\/$/,"")))}async function vt(n,e,t,r,o="standard",i){r?.(`Researching what ranks for "${t}"`);let s=e.pages.map(w=>({url:w.url,title:w.title})).slice(0,40),a="You are a senior content strategist producing a brief for a specific website. First, actually search the web for the target keyword and study the pages that rank \u2014 score each competitor honestly from what you observed. Apply the Website Relevance Rule: every heading, subtopic and FAQ must be something THIS site can credibly write about given what it is (judge from its crawled pages) \u2014 if the site can't deliver on it, leave it out. Exclude non-competitors (Wikipedia, Reddit, YouTube, generic directories) from competitor scoring but note them if they dominate the SERP.",d=[`Target keyword: "${t}"`,`The site this brief is for: ${e.domain}`,`Its crawled pages:
50
- ${JSON.stringify(s,null,1)}`,$r(o,i),"Search the keyword, study what ranks, then produce the brief. The informationGain field is the heart of it: name the specific new value no ranking page currently provides that this site is positioned to add. Internal link suggestions must be EXACT URLs from the crawled list."].filter(Boolean).join(`
50
+ `),jsonSchema:Ur,webSearch:!0,tier:"light"});for(let p of y.results){let g=t.keywords.find(c=>c.keyword.toLowerCase()===p.keyword.toLowerCase());!g||p.consensusType==="other"||(g.serp={consensusType:p.consensusType,sitePageType:p.sitePageType,mismatch:Br(p.sitePageType,p.consensusType),competitorsSeen:p.competitorsSeen.slice(0,5),advice:p.advice},i?.kvSet(a(g.keyword),g.serp))}}import{randomUUID as zr}from"crypto";var Wr={type:"object",additionalProperties:!1,required:["searchIntent","recommendedPageType","titleSuggestion","metaDescription","informationGain","outline","faq","internalLinks","competitors"],properties:{searchIntent:{type:"string",description:"What the searcher actually wants, in one sentence"},recommendedPageType:{type:"string",description:"The page type that fits this SERP (guide, comparison, product, tool\u2026)"},titleSuggestion:{type:"string",description:"50-60 characters, primary keyword front-loaded"},metaDescription:{type:"string",description:"130-150 characters, no quote characters"},informationGain:{type:"string",description:"NON-NEGOTIABLE: exactly what NEW value this content adds that NO current ranking page provides. Never 'more detail' or 'better formatting' \u2014 a specific angle, data set, tool, or perspective."},outline:{type:"array",maxItems:10,items:{type:"object",additionalProperties:!1,required:["heading","keyPoints","targetWords"],properties:{heading:{type:"string",description:"H2/H3 phrased the way searchers phrase queries where natural"},keyPoints:{type:"array",maxItems:5,items:{type:"string"}},targetWords:{type:"integer",description:"Target words for the section; lead sections should be 40-60 word direct answers"}}}},faq:{type:"array",maxItems:6,items:{type:"object",additionalProperties:!1,required:["question","answerAngle"],properties:{question:{type:"string"},answerAngle:{type:"string"}}}},internalLinks:{type:"array",maxItems:8,items:{type:"string"},description:"URLs from the crawled-pages list (EXACT URLs from that list only) that should link to or from this new content"},competitors:{type:"array",maxItems:5,items:{type:"object",additionalProperties:!1,required:["domain","scores","strength","weakness"],properties:{domain:{type:"string",description:"A domain you actually saw ranking for this query"},scores:{type:"object",additionalProperties:!1,required:["depth","formatting","seo","ux"],properties:{depth:{type:"integer",minimum:1,maximum:10},formatting:{type:"integer",minimum:1,maximum:10},seo:{type:"integer",minimum:1,maximum:10},ux:{type:"integer",minimum:1,maximum:10}}},strength:{type:"string"},weakness:{type:"string"}}}}}};function Vr(n,e){switch(n){case"vs-competitor":return`PAGE FORMAT: "[this site's product] vs ${e??"[competitor]"}" comparison page. Structure requirements: a scannable comparison table early; an honest "when ${e??"the competitor"} is the better choice" section (fairness earns trust AND AI citations \u2014 one-sided comparisons get ignored); a migration/switching section; feature-by-feature depth on the 3-4 axes buyers actually decide on (research what reviewers say). The outline MUST include these sections.`;case"alternatives":return`PAGE FORMAT: "${e??"[competitor]"} alternatives" page. Search the web for what users complain about with ${e??"the competitor"} (reviews, forums) \u2014 the page must lead with those real switching triggers, list 4-6 genuine alternatives INCLUDING this site's product honestly positioned (not always #1 on every axis), with a comparison table and per-alternative "best for" verdicts.`;case"best-roundup":return`PAGE FORMAT: "best [category]" roundup that includes this site's own product. CRITICAL: self-promotional listicles are excluded from AI answers ~69% of the time when they read as ads. The brief must define objective evaluation criteria stated on the page, honest per-product strengths/weaknesses including this site's product, and at least one scenario where a competitor wins. That honesty is the mechanism that gets the page cited.`;default:return""}}function Qr(n,e){let t=new Set(e.pages.map(r=>r.url.replace(/\/$/,"")));return n.filter(r=>t.has(r.replace(/\/$/,"")))}async function jt(n,e,t,r,o="standard",i){r?.(`Researching what ranks for "${t}"`);let s=e.pages.map(y=>({url:y.url,title:y.title})).slice(0,40),a="You are a senior content strategist producing a brief for a specific website. First, actually search the web for the target keyword and study the pages that rank \u2014 score each competitor honestly from what you observed. Apply the Website Relevance Rule: every heading, subtopic and FAQ must be something THIS site can credibly write about given what it is (judge from its crawled pages) \u2014 if the site can't deliver on it, leave it out. Exclude non-competitors (Wikipedia, Reddit, YouTube, generic directories) from competitor scoring but note them if they dominate the SERP.",d=[`Target keyword: "${t}"`,`The site this brief is for: ${e.domain}`,`Its crawled pages:
51
+ ${JSON.stringify(s,null,1)}`,Vr(o,i),"Search the keyword, study what ranks, then produce the brief. The informationGain field is the heart of it: name the specific new value no ranking page currently provides that this site is positioned to add. Internal link suggestions must be EXACT URLs from the crawled list."].filter(Boolean).join(`
51
52
 
52
- `),m=await n.analyzeJson({system:a,prompt:d,jsonSchema:Pr,webSearch:!0});return r?.("Grounding internal links against the crawl"),Ee.parse({...m,id:Sr(),type:"content-brief",domain:e.domain,keyword:t,createdAt:new Date().toISOString(),format:o,competitorName:i,internalLinks:Rr(m.internalLinks,e)})}async function kt(n,e,t,r=3e4){let o=new Map;if(t.length===0)return o;let i=await fetch("https://api.dataforseo.com/v3/keywords_data/google_ads/search_volume/live",{method:"POST",headers:{authorization:`Basic ${Buffer.from(`${n}:${e}`).toString("base64")}`,"content-type":"application/json"},body:JSON.stringify([{keywords:t.slice(0,100),language_code:"en"}]),signal:AbortSignal.timeout(r)});if(!i.ok)throw new Error(`DataForSEO ${i.status}: ${(await i.text()).slice(0,200)}`);let s=await i.json();if(s.status_code!==2e4)throw new Error(`DataForSEO error status ${s.status_code}`);for(let a of s.tasks??[])for(let d of a.result??[])d.search_volume!==null&&d.search_volume!==void 0&&o.set(d.keyword.toLowerCase(),d.search_volume);return o}import{randomUUID as Cr}from"crypto";var Pe=class{info;listeners=new Set;payload=null;constructor(e){this.info={id:Cr(),kind:e,status:"running",events:[]}}emit(e,t,r){let o={ts:Date.now(),kind:e,message:t,fraction:r};this.info.events.push(o),this.info.events.length>500&&this.info.events.splice(0,this.info.events.length-500);for(let i of this.listeners)i(o)}done(e){this.info.status="done",this.info.resultId=e,this.emit("done",e??"done")}fail(e){this.info.status="error",this.info.error=e instanceof Error?e.message:String(e),this.emit("error",this.info.error)}subscribe(e){return this.listeners.add(e),()=>this.listeners.delete(e)}},xt=new Map;function H(n,e){let t=new Pe(n);return xt.set(t.info.id,t),e(t).catch(r=>{t.info.status==="running"&&t.fail(r)}),t}function $e(n){return xt.get(n)}var Or={".html":"text/html",".js":"text/javascript",".css":"text/css",".svg":"image/svg+xml",".png":"image/png",".ico":"image/x-icon",".woff2":"font/woff2",".json":"application/json"};function Lr(){let n=q.dirname(Ir(import.meta.url));for(let e of[q.join(n,"ui"),q.join(n,"../../dist/ui")])if(X.existsSync(q.join(e,"index.html")))return e;return null}function Tr(){let n=new jr,e=async l=>{let c=ee(),p=c.kvGet("gsc:access-token",45*6e4);if(p)return p;let h=await ft(l.clientId,l.clientSecret,l.refreshToken);return c.kvSet("gsc:access-token",h),h},t=()=>{let l=F();if(!l)throw new Error("Not configured \u2014 run `pulse-seo` in a terminal first");return l};n.get("/api/health",l=>l.json({ok:!0,version:"0.1.0"})),n.get("/api/config",l=>{let c=F();if(!c)return l.json({configured:!1});let p=C(c);return l.json({configured:!0,provider:c.provider,model:c.model??null,crawl:c.crawl,performance:c.performance,economy:c.economy,auth:p.authStatus(),hasAnthropicKey:!!c.anthropicApiKey,hasOpenaiKey:!!c.openaiApiKey,hasGeminiKey:!!c.geminiApiKey,hasPsiKey:!!c.psiApiKey,hasDataForSeo:!!(c.dataForSeoLogin&&c.dataForSeoPassword),gsc:{configured:!!c.gsc,connected:!!c.gsc?.refreshToken}})});let r=x.object({provider:x.enum(["claude","openai","gemini"]).optional(),model:x.string().nullable().optional(),maxPages:x.number().int().min(1).max(200).optional(),render:x.boolean().optional(),performance:x.boolean().optional(),economy:x.boolean().optional(),psiApiKey:x.string().nullable().optional(),dataForSeoLogin:x.string().nullable().optional(),dataForSeoPassword:x.string().nullable().optional()});n.put("/api/config",async l=>{let c=r.parse(await l.req.json()),p=t();return c.provider&&(p.provider=c.provider),c.model!==void 0&&(p.model=c.model??void 0),c.maxPages&&(p.crawl.maxPages=c.maxPages),c.render!==void 0&&(p.crawl.render=c.render),c.performance!==void 0&&(p.performance=c.performance),c.economy!==void 0&&(p.economy=c.economy),c.psiApiKey!==void 0&&(p.psiApiKey=c.psiApiKey??void 0),c.dataForSeoLogin!==void 0&&(p.dataForSeoLogin=c.dataForSeoLogin??void 0),c.dataForSeoPassword!==void 0&&(p.dataForSeoPassword=c.dataForSeoPassword??void 0),B(p),l.json({ok:!0})});let o=x.object({domain:x.string().min(3),maxPages:x.number().int().min(1).max(200).optional(),skipLlm:x.boolean().optional()});n.post("/api/audits",async l=>{let c=o.parse(await l.req.json()),p=t(),h=C(p),b=H("domain-audit",async P=>{let f=await Y(h,c.domain,{maxPages:c.maxPages??p.crawl.maxPages,timeoutMs:p.crawl.timeoutMs,skipLlm:c.skipLlm,render:p.crawl.render,measurePerformance:p.performance,psiApiKey:p.psiApiKey,cache:ee(),onProgress:(A,$)=>P.emit("progress",A,$)});M(f),P.done(f.id)});return l.json({jobId:b.info.id})});let i=x.object({auditId:x.string(),keywords:x.array(x.string().min(1)).max(30),serpFit:x.boolean().optional()});n.post("/api/keywords",async l=>{let c=i.parse(await l.req.json()),p=t(),h=C(p),b=_(c.auditId);if(!b||b.type!=="domain")return l.json({error:"audit not found"},404);let P=H("keywords",async f=>{let A={};if(p.gsc?.refreshToken){f.emit("progress","Fetching real Search Console data",.1);try{let v=await e(p.gsc),j=Ae(await xe(v),b.domain);j?A.gscQueries=await Se(v,j):f.emit("log",`No Search Console property matches ${b.domain}`)}catch(v){f.emit("log",`Search Console fetch failed: ${v instanceof Error?v.message:v}`)}}f.emit("progress",`Analyzing ${c.keywords.length||"auto-detected"} keywords against ${b.pages.length} pages`,.3);let $=await ot(h,b,c.keywords,A);if(p.dataForSeoLogin&&p.dataForSeoPassword){f.emit("progress","Fetching search volumes (DataForSEO)",.8);try{let v=await kt(p.dataForSeoLogin,p.dataForSeoPassword,$.keywords.map(j=>j.keyword));for(let j of $.keywords){let U=v.get(j.keyword.toLowerCase());U!==void 0&&(j.searchVolume=U)}}catch(v){f.emit("log",`DataForSEO fetch failed: ${v instanceof Error?v.message:v}`)}}if(c.serpFit){f.emit("progress","Analyzing SERP fit via web search",.9);try{await bt(h,b,$,5,v=>f.emit("log",v),ee())}catch(v){f.emit("log",`SERP fit analysis failed: ${v instanceof Error?v.message:v}`)}}M($),f.done($.id)});return l.json({jobId:P.info.id})});let s=x.object({auditId:x.string(),prompts:x.array(x.string()).max(15).default([])});n.post("/api/visibility",async l=>{let c=s.parse(await l.req.json()),p=t(),h=C(p),b=_(c.auditId);if(!b||b.type!=="domain")return l.json({error:"audit not found"},404);let P=H("ai-visibility",async f=>{let A=await ut(h,b,c.prompts,($,v)=>f.emit("progress",$,v));M(A),f.done(A.id)});return l.json({jobId:P.info.id})});let a=x.object({clientId:x.string().min(10),clientSecret:x.string().min(5)});n.post("/api/gsc/credentials",async l=>{let c=a.parse(await l.req.json()),p=t();return p.gsc={clientId:c.clientId.trim(),clientSecret:c.clientSecret.trim()},B(p),l.json({ok:!0})}),n.get("/api/gsc/connect",l=>{let c=t();if(!c.gsc)return l.json({error:"Save your Google OAuth client ID and secret first."},400);let p=`${new URL(l.req.url).origin}/api/gsc/callback`;return l.json({authUrl:gt(c.gsc.clientId,p)})}),n.get("/api/gsc/callback",async l=>{let c=l.req.query("code"),p=t();if(!c||!p.gsc)return l.text("Missing code or GSC configuration",400);let h=`${new URL(l.req.url).origin}/api/gsc/callback`,{refreshToken:b}=await ht(p.gsc.clientId,p.gsc.clientSecret,c,h);return p.gsc.refreshToken=b,B(p),l.redirect("/settings?gsc=connected")}),n.get("/api/gsc/queries",async l=>{let c=l.req.query("domain")??"",p=t();if(!p.gsc?.refreshToken)return l.json({error:"Search Console is not connected"},400);let h=await e(p.gsc),b=Ae(await xe(h),c);return b?l.json({property:b,rows:await Se(h,b)}):l.json({error:`No Search Console property matches ${c}`},404)});let d=x.object({path:x.string().min(1),skipLlm:x.boolean().optional()});n.post("/api/codebase/scan",async l=>{let c=d.parse(await l.req.json()),p=t(),h=C(p),b=H("codebase-scan",async P=>{P.emit("progress",`Round 1: rule scan of ${c.path}`,.1);let f=pe(c.path);P.emit("progress",`Detected ${f.framework}; ${f.files.length} source files`,.2);let A=ue(f);if(!c.skipLlm){P.emit("progress","Round 2: AI agent exploring the codebase (read-only)",.3);try{let v=await Ne(h,f.root,f.framework,A,j=>P.emit("agent",j.kind==="tool"?`[tool] ${j.message}`:j.message));A=[...A,...v],P.emit("progress",`AI auditor added ${v.length} finding(s)`,.7)}catch(v){P.emit("log",`AI audit round failed: ${v instanceof Error?v.message:v}`)}}let $=ge(f,A);if(!c.skipLlm){P.emit("progress","Writing the report",.85);try{$.llmReport=await Z(h,{subject:`${f.framework} codebase at ${f.root}`,scorecard:$.scorecard,findings:A,contentSamples:f.files.slice(0,4).map(v=>({location:v.rel,sample:v.content.slice(0,1e3)}))})}catch(v){P.emit("log",`AI report failed: ${v instanceof Error?v.message:v}`)}}M($),P.done($.id)});return l.json({jobId:b.info.id})});let m=x.object({reportId:x.string(),findingId:x.string(),allowDirty:x.boolean().optional()});n.post("/api/codebase/fix",async l=>{let c=m.parse(await l.req.json()),p=t(),h=C(p),b=_(c.reportId);if(!b||b.type!=="codebase")return l.json({error:"codebase report not found"},404);let P=b.findings.find(A=>A.id===c.findingId);if(!P)return l.json({error:"finding not found"},404);if(!await at(b.path))return l.json({error:"Not a git repository \u2014 auto-fix requires git so changes are revertible."},400);if(!c.allowDirty&&!await ct(b.path))return l.json({error:"dirty-tree",detail:"The working tree has uncommitted changes. Commit/stash them, or retry allowing a dirty tree."},409);let f=H("codebase-fix",async A=>{A.emit("progress",`Agent fixing: ${P.title}`,.1);let $=await dt(h,b.path,P,b.framework,v=>A.emit("agent",v.kind==="tool"?`[tool] ${v.message}`:v.message));A.payload=$,A.done()});return l.json({jobId:f.info.id})});let w=x.object({reportId:x.string(),files:x.array(x.string()).min(1)});n.post("/api/codebase/revert",async l=>{let c=w.parse(await l.req.json()),p=_(c.reportId);return!p||p.type!=="codebase"?l.json({error:"codebase report not found"},404):(await lt(p.path,c.files),l.json({ok:!0}))});let u=x.object({auditId:x.string(),keyword:x.string().min(2).max(120),format:x.enum(["standard","vs-competitor","alternatives","best-roundup"]).optional(),competitorName:x.string().max(80).optional()});n.post("/api/briefs",async l=>{let c=u.parse(await l.req.json()),p=t(),h=C(p),b=_(c.auditId);if(!b||b.type!=="domain")return l.json({error:"audit not found"},404);let P=H("content-brief",async f=>{f.emit("progress",`Building ${c.format??"standard"} brief for "${c.keyword}"`,.1);let A=await vt(h,b,c.keyword,$=>f.emit("progress",$,.5),c.format??"standard",c.competitorName);M(A),f.done(A.id)});return l.json({jobId:P.info.id})}),n.get("/api/drift",l=>{let c=l.req.query("before"),p=l.req.query("after");if(!c||!p)return l.json({error:"before and after report ids required"},400);let h=_(c),b=_(p);return!h||h.type!=="domain"||!b||b.type!=="domain"?l.json({error:"both ids must be domain audit reports"},404):l.json({before:{id:h.id,createdAt:h.createdAt,overall:h.scorecard.overall},after:{id:b.id,createdAt:b.createdAt,overall:b.scorecard.overall},domain:b.domain,changes:Fe(h,b)})}),n.get("/api/jobs/:id",l=>{let c=$e(l.req.param("id"));return c?l.json({...c.info,payload:c.payload}):l.json({error:"not found"},404)}),n.get("/api/jobs/:id/events",l=>{let c=$e(l.req.param("id"));return c?Er(l,async p=>{for(let h of c.info.events)await p.writeSSE({data:JSON.stringify(h)});c.info.status==="running"&&await new Promise(h=>{let b=c.subscribe(async P=>{await p.writeSSE({data:JSON.stringify(P)}),(P.kind==="done"||P.kind==="error")&&(b(),h())});p.onAbort(()=>{b(),h()})})}):l.json({error:"not found"},404)}),n.get("/api/reports",l=>l.json(Je())),n.get("/api/reports/:id",l=>{let c=_(l.req.param("id"));return c?l.json(c):l.json({error:"not found"},404)}),n.delete("/api/reports/:id",l=>l.json({ok:He(l.req.param("id"))})),n.get("/api/reports/:id/export",async l=>{let c=_(l.req.param("id"));if(!c)return l.json({error:"not found"},404);let p=await qe(c);return l.body(new Uint8Array(p),200,{"content-type":"application/pdf","content-disposition":`attachment; filename="${Ge(c)}"`})});let g=Lr();return n.get("*",l=>{if(!g)return l.text("UI not built. Run `npm run build:ui`.",500);let c=l.req.path==="/"?"/index.html":l.req.path,p=q.join(g,q.normalize(c));if(p.startsWith(g)&&X.existsSync(p)&&X.statSync(p).isFile()){let h=q.extname(p);return l.body(X.readFileSync(p),200,{"content-type":Or[h]??"application/octet-stream"})}return l.html(X.readFileSync(q.join(g,"index.html"),"utf8"))}),n.onError((l,c)=>c.json({error:l.message},500)),n}function St(n){let e=Tr();return new Promise((t,r)=>{At({fetch:e.fetch,port:n,hostname:"127.0.0.1"},i=>t(i.port)).on("error",i=>{i.code==="EADDRINUSE"?At({fetch:e.fetch,port:0,hostname:"127.0.0.1"},a=>t(a.port)).on("error",r):r(i)})})}var J=new _r;async function Pt(n,e){let{renderReportPdf:t,reportFilename:r}=await import("./exportPdf-26EHUONL.js"),o=Mr.resolve(typeof e=="string"?e:r(n));process.stderr.write(S.dim(` Rendering PDF\u2026
53
- `)),Dr.writeFileSync(o,await t(n)),process.stderr.write(` PDF saved to ${S.cyan(o)}
54
- `)}function $t(n,e,t){let r=i=>i>=80?S.green:i>=55?S.yellow:S.red;console.log(""),console.log(S.bold(` ${n}`)),console.log(` Overall: ${r(e.overall)(S.bold(`${e.overall}/100`))}`);for(let i of e.categories){let s="\u2588".repeat(Math.round(i.score/5)).padEnd(20,"\u2591");console.log(` ${i.label.padEnd(18)} ${r(i.score)(s)} ${String(i.score).padStart(3)}`)}let o={critical:0,warning:0,info:0};for(let i of t)i.severity in o&&o[i.severity]++;console.log(`
55
- ${S.red(`${o.critical} critical`)} ${S.yellow(`${o.warning} warnings`)} ${S.dim(`${o.info} suggestions`)}`);for(let i of t.filter(s=>s.severity==="critical"))console.log(` ${S.red("\u2717")} ${i.title} ${S.dim(`(${i.occurrences}\xD7)`)}`);console.log("")}J.name("pulse-seo").description("SEO & AEO auditor for domains and codebases, powered by Claude or OpenAI").version("0.1.0");J.command("start",{isDefault:!0}).description("Start the web UI (default command)").option("-p, --port <port>","port for the local server","4173").option("--no-open","don't open the browser automatically").action(async n=>{let e=await W();ae(e,{needsAi:!0,mode:"warn"});let r=`http://localhost:${await St(Number(n.port))}`;console.log(`
56
- ${S.bold("Pulse Media SEO/AEO Audit")} running at ${S.cyan(r)}
57
- ${S.dim("Press Ctrl+C to stop.")}
58
- `),n.open&&await Nr(r).catch(()=>{})});J.command("login").description("(Re)configure provider, authentication and model").action(async()=>{await be(F())});J.command("audit <domain>").description("Audit a domain from the terminal (no UI)").option("--pages <n>","max pages to crawl","25").option("--no-ai","skip the AI narrative report").option("--render","render pages with headless Chrome (JS execution)").option("--no-perf","skip Core Web Vitals measurement").option("--json","output the full report as JSON").option("--pdf [file]","also save the report as a PDF").action(async(n,e)=>{let t=await W();ae(t,{needsAi:e.ai,mode:"strict"});let r=C(t),o=await Y(r,n,{maxPages:Number(e.pages),timeoutMs:t.crawl.timeoutMs,skipLlm:!e.ai,render:e.render??t.crawl.render,measurePerformance:e.perf&&t.performance,psiApiKey:t.psiApiKey,cache:(await import("./store-PDEFA2OG.js")).getStore(),onProgress:i=>{e.json||process.stderr.write(`\r\x1B[2K ${S.dim(i.slice(0,100))}`)}});if(e.json||process.stderr.write("\r\x1B[2K"),M(o),e.pdf&&await Pt(o,e.pdf),e.json){console.log(JSON.stringify(o,null,2));return}if($t(o.domain,o.scorecard,o.findings),o.llmReport){console.log(` ${S.bold("Summary:")} ${o.llmReport.summary}
59
- `);for(let i of o.llmReport.actionPlan.slice(0,5))console.log(` ${S.cyan(`${i.priority}.`)} ${i.title} ${S.dim(`[impact: ${i.impact}, effort: ${i.effort}]`)}`);console.log("")}console.log(S.dim(" Full report saved \u2014 open the UI with `pulse-seo` to explore it.\n"))});J.command("scan [path]").description("Audit a codebase from the terminal (no UI)").option("--no-ai","skip the AI narrative report").option("--json","output the full report as JSON").option("--pdf [file]","also save the report as a PDF").action(async(n,e)=>{let t=await W();ae(t,{needsAi:e.ai,mode:"strict"});let r=pe(n??process.cwd()),o=ue(r),i=C(t);if(e.ai){process.stderr.write(S.dim(` Round 2: AI agent exploring the codebase (read-only)\u2026
60
- `));try{let{aiCodebaseAudit:a}=await import("./aiAudit-5EL5LJKD.js"),d=await a(i,r.root,r.framework,o,m=>{m.kind==="tool"&&process.stderr.write(S.dim(` ${m.message}
61
- `))});o=[...o,...d]}catch(a){process.stderr.write(S.yellow(` AI audit round failed: ${a instanceof Error?a.message:a}
62
- `))}}let s=ge(r,o);if(e.ai){let{buildLlmReport:a}=await import("./report-VNSGKYVL.js");process.stderr.write(S.dim(` Writing the narrative report and action plan (may take a minute)\u2026
63
- `));try{s.llmReport=await a(i,{subject:`${r.framework} codebase at ${r.root}`,scorecard:s.scorecard,findings:o,contentSamples:r.files.slice(0,4).map(d=>({location:d.rel,sample:d.content.slice(0,1e3)}))})}catch{}}if(M(s),e.pdf&&await Pt(s,e.pdf),e.json){console.log(JSON.stringify(s,null,2));return}$t(`${r.root} (${r.framework}, ${r.files.length} files)`,s.scorecard,s.findings),console.log(S.dim(" Open the UI with `pulse-seo` to review findings and apply AI fixes.\n"))});J.command("drift <domain>").description("Re-audit a domain, compare with the previous run, and exit 1 on regressions (cron/CI friendly)").option("--pages <n>","max pages to crawl","15").option("--fail-on <severity>","exit non-zero at this drift severity or worse: critical|warning|info","critical").option("--json","output drift as JSON").action(async(n,e)=>{let t=await W(),{normalizeDomain:r}=await import("./crawler-LJDICWL6.js"),{compareAudits:o,driftExitCode:i}=await import("./drift-WE75HT5C.js"),{getStore:s}=await import("./store-PDEFA2OG.js"),a=r(n),d=s().findLatestDomainReport(a),m=await Y(null,n,{maxPages:Number(e.pages),timeoutMs:t.crawl.timeoutMs,skipLlm:!0,render:t.crawl.render,onProgress:g=>{e.json||process.stderr.write(`\r\x1B[2K ${S.dim(g.slice(0,100))}`)}});if(e.json||process.stderr.write("\r\x1B[2K"),M(m),!d||d.type!=="domain"){let g=`Baseline created for ${a} (${m.pagesCrawled} pages, score ${m.scorecard.overall}). Run again after changes to detect drift.`;console.log(e.json?JSON.stringify({baseline:!0,reportId:m.id}):` ${g}`);return}let w=o(d,m);if(e.json)console.log(JSON.stringify({domain:a,before:d.id,after:m.id,changes:w},null,2));else{console.log(`
64
- ${S.bold(a)} ${d.scorecard.overall} \u2192 ${m.scorecard.overall} ${S.dim(`(baseline ${new Date(d.createdAt).toLocaleString()})`)}
65
- `),w.length===0&&console.log(S.green(` No SEO-relevant drift detected.
66
- `));for(let g of w){let l=g.severity==="critical"?S.red:g.severity==="warning"?S.yellow:S.dim;console.log(` ${l(`[${g.severity}]`)} ${g.field} ${S.dim(g.url)}
67
- ${S.red(`- ${g.before.slice(0,90)}`)}
68
- ${S.green(`+ ${g.after.slice(0,90)}`)}`)}w.length>0&&console.log("")}let u=i(w,["critical","warning","info"].includes(e.failOn)?e.failOn:"critical");process.exit(u)});J.command("config").description("Show the current configuration").action(()=>{let n=F();if(!n){console.log(`Not configured yet \u2014 run ${S.cyan("pulse-seo login")}.`);return}console.log(` Config file: ${Oe}`),console.log(` Provider: ${n.provider}`),console.log(` Model: ${n.model??"(provider default)"}`),console.log(` Auth: ${C(n).authStatus().detail}`),console.log(` Crawl limit: ${n.crawl.maxPages} pages`)});J.parseAsync().catch(n=>{console.error(S.red(`
53
+ `),m=await n.analyzeJson({system:a,prompt:d,jsonSchema:Wr,webSearch:!0});return r?.("Grounding internal links against the crawl"),Ge.parse({...m,id:zr(),type:"content-brief",domain:e.domain,keyword:t,createdAt:new Date().toISOString(),format:o,competitorName:i,internalLinks:Qr(m.internalLinks,e)})}async function Ot(n,e,t,r=3e4){let o=new Map;if(t.length===0)return o;let i=await fetch("https://api.dataforseo.com/v3/keywords_data/google_ads/search_volume/live",{method:"POST",headers:{authorization:`Basic ${Buffer.from(`${n}:${e}`).toString("base64")}`,"content-type":"application/json"},body:JSON.stringify([{keywords:t.slice(0,100),language_code:"en"}]),signal:AbortSignal.timeout(r)});if(!i.ok)throw new Error(`DataForSEO ${i.status}: ${(await i.text()).slice(0,200)}`);let s=await i.json();if(s.status_code!==2e4)throw new Error(`DataForSEO error status ${s.status_code}`);for(let a of s.tasks??[])for(let d of a.result??[])d.search_volume!==null&&d.search_volume!==void 0&&o.set(d.keyword.toLowerCase(),d.search_volume);return o}var z="0.4.3";import{randomUUID as io}from"crypto";import Oe from"fs";import Yr from"os";import Le from"path";import{createRequire as Xr}from"module";var Lt=Le.join(ye,"last-error.txt"),Zr=6e3,eo=300,to=20,no=12,ro=[[/sk-[A-Za-z0-9_-]{8,}/g,"[redacted]"],[/AIza[0-9A-Za-z_-]{20,}/g,"[redacted]"],[/1\/\/[0-9A-Za-z_-]{20,}/g,"[redacted]"],[/Bearer\s+\S+/gi,"Bearer [redacted]"],[/([?&](?:key|api_key|token|access_token)=)[^&\s]+/gi,"$1[redacted]"]];function _t(){try{return M()}catch{return null}}function Mt(n){return n?[n.anthropicApiKey,n.openaiApiKey,n.geminiApiKey,n.psiApiKey,n.dataForSeoPassword,n.gsc?.clientSecret,n.gsc?.refreshToken].filter(e=>!!e):[]}function W(n,e=Mt(_t())){let t=n;for(let o of e)t=t.split(o).join("[redacted]");for(let[o,i]of ro)t=t.replace(o,i);let r=Yr.homedir();return r?t.split(r).join("~").split(r.replace(/\\/g,"/")).join("~"):t}function oo(){try{let n=Xr(import.meta.url).resolve("@anthropic-ai/claude-agent-sdk");return JSON.parse(Oe.readFileSync(Le.join(Le.dirname(n),"package.json"),"utf8")).version??"unknown"}catch{return"unknown"}}function Tt(n,e){return n.length>e?`${n.slice(0,e)}\u2026`:n}function ee(n){let e=_t(),t=n.error instanceof Error?n.error.message:String(n.error),r=n.error instanceof Error?(n.error.stack??"").split(`
54
+ `).filter(i=>i.trimStart().startsWith("at ")):[],o=["Pulse SEO error report",`Time: ${new Date().toISOString()}`,`Where: ${n.source}`,`Version: ${z}`,`Claude Agent SDK: ${oo()}`,`Provider: ${e?.provider??"not configured"}`,`Model: ${e?.model??"provider default"}`,`Auth: ${e?.authMethod??"unknown"}`,`Platform: ${process.platform} ${process.arch}, Node ${process.version}`,"","Error:",Tt(t,Zr)];if(r.length>0&&o.push("","Stack:",...r.slice(0,no)),n.events?.length){o.push("","Recent activity:");for(let i of n.events.slice(-to))o.push(`${new Date(i.ts).toISOString()} [${i.kind}] ${Tt(i.message,eo)}`)}return W(o.join(`
55
+ `),Mt(e))}function te(n){try{return Oe.mkdirSync(ye,{recursive:!0}),Oe.writeFileSync(Lt,`${n}
56
+ `,{mode:384}),Lt}catch{return null}}var Te=class{info;listeners=new Set;payload=null;constructor(e){this.info={id:io(),kind:e,status:"running",events:[]}}emit(e,t,r,o){let i={ts:Date.now(),kind:e,message:t,fraction:r,diagnostics:o};this.info.events.push(i),this.info.events.length>500&&this.info.events.splice(0,this.info.events.length-500);for(let s of this.listeners)s(i)}done(e){this.info.status="done",this.info.resultId=e,this.emit("done",e??"done")}fail(e){this.info.status="error",this.info.error=W(e instanceof Error?e.message:String(e)),this.info.diagnostics=ee({source:`${this.info.kind} job ${this.info.id}`,error:e,events:this.info.events}),te(this.info.diagnostics),this.emit("error",this.info.error,void 0,this.info.diagnostics)}warn(e,t){let r=W(`${e}: ${t instanceof Error?t.message:String(t)}`),o=ee({source:`${this.info.kind} job ${this.info.id}: ${e}`,error:t,events:this.info.events});te(o),this.emit("warning",r,void 0,o)}subscribe(e){return this.listeners.add(e),()=>this.listeners.delete(e)}},Nt=new Map;function H(n,e){let t=new Te(n);return Nt.set(t.info.id,t),e(t).catch(r=>{t.info.status==="running"&&t.fail(r)}),t}function _e(n){return Nt.get(n)}var lo={".html":"text/html",".js":"text/javascript",".css":"text/css",".svg":"image/svg+xml",".png":"image/png",".ico":"image/x-icon",".woff2":"font/woff2",".json":"application/json"};function po(){let n=q.dirname(so(import.meta.url));for(let e of[q.join(n,"ui"),q.join(n,"../../dist/ui")])if(ne.existsSync(q.join(e,"index.html")))return e;return null}function uo(){let n=new ao,e=async c=>{let l=ie(),u=l.kvGet("gsc:access-token",45*6e4);if(u)return u;let h=await Rt(c.clientId,c.clientSecret,c.refreshToken);return l.kvSet("gsc:access-token",h),h},t=()=>{let c=M();if(!c)throw new Error("Not configured \u2014 run `pulse-seo` in a terminal first");return c};n.get("/api/health",c=>c.json({ok:!0,version:z})),n.get("/api/config",c=>{let l=M();if(!l)return c.json({configured:!1});let u=C(l);return c.json({configured:!0,provider:l.provider,model:l.model??null,crawl:l.crawl,performance:l.performance,economy:l.economy,auth:u.authStatus(),hasAnthropicKey:!!l.anthropicApiKey,hasOpenaiKey:!!l.openaiApiKey,hasGeminiKey:!!l.geminiApiKey,hasPsiKey:!!l.psiApiKey,hasDataForSeo:!!(l.dataForSeoLogin&&l.dataForSeoPassword),gsc:{configured:!!l.gsc,connected:!!l.gsc?.refreshToken}})});let r=A.object({provider:A.enum(["claude","openai","gemini"]).optional(),model:A.string().nullable().optional(),maxPages:A.number().int().min(1).max(200).optional(),render:A.boolean().optional(),performance:A.boolean().optional(),economy:A.boolean().optional(),psiApiKey:A.string().nullable().optional(),dataForSeoLogin:A.string().nullable().optional(),dataForSeoPassword:A.string().nullable().optional()});n.put("/api/config",async c=>{let l=r.parse(await c.req.json()),u=t();return l.provider&&(u.provider=l.provider),l.model!==void 0&&(u.model=l.model??void 0),l.maxPages&&(u.crawl.maxPages=l.maxPages),l.render!==void 0&&(u.crawl.render=l.render),l.performance!==void 0&&(u.performance=l.performance),l.economy!==void 0&&(u.economy=l.economy),l.psiApiKey!==void 0&&(u.psiApiKey=l.psiApiKey??void 0),l.dataForSeoLogin!==void 0&&(u.dataForSeoLogin=l.dataForSeoLogin??void 0),l.dataForSeoPassword!==void 0&&(u.dataForSeoPassword=l.dataForSeoPassword??void 0),B(u),c.json({ok:!0})});let o=A.object({domain:A.string().min(3),maxPages:A.number().int().min(1).max(200).optional(),skipLlm:A.boolean().optional()});n.post("/api/audits",async c=>{let l=o.parse(await c.req.json()),u=t(),h=C(u),b=H("domain-audit",async P=>{let f=await Z(h,l.domain,{maxPages:l.maxPages??u.crawl.maxPages,timeoutMs:u.crawl.timeoutMs,skipLlm:l.skipLlm,render:u.crawl.render,measurePerformance:u.performance,psiApiKey:u.psiApiKey,cache:ie(),onProgress:(S,$)=>P.emit("progress",S,$),onIssue:(S,$)=>P.warn(S,$)});N(f),P.done(f.id)});return c.json({jobId:b.info.id})});let i=A.object({auditId:A.string(),keywords:A.array(A.string().min(1)).max(30),serpFit:A.boolean().optional()});n.post("/api/keywords",async c=>{let l=i.parse(await c.req.json()),u=t(),h=C(u),b=D(l.auditId);if(!b||b.type!=="domain")return c.json({error:"audit not found"},404);let P=H("keywords",async f=>{let S={};if(u.gsc?.refreshToken){f.emit("progress","Fetching real Search Console data",.1);try{let x=await e(u.gsc),E=Ee(await Ie(x),b.domain);E?S.gscQueries=await je(x,E):f.emit("log",`No Search Console property matches ${b.domain}`)}catch(x){f.warn("Search Console fetch failed",x)}}f.emit("progress",`Analyzing ${l.keywords.length||"auto-detected"} keywords against ${b.pages.length} pages`,.3);let $=await ht(h,b,l.keywords,S);if(u.dataForSeoLogin&&u.dataForSeoPassword){f.emit("progress","Fetching search volumes (DataForSEO)",.8);try{let x=await Ot(u.dataForSeoLogin,u.dataForSeoPassword,$.keywords.map(E=>E.keyword));for(let E of $.keywords){let U=x.get(E.keyword.toLowerCase());U!==void 0&&(E.searchVolume=U)}}catch(x){f.warn("DataForSEO fetch failed",x)}}if(l.serpFit){f.emit("progress","Analyzing SERP fit via web search",.9);try{await Et(h,b,$,5,x=>f.emit("log",x),ie())}catch(x){f.warn("SERP fit analysis failed",x)}}N($),f.done($.id)});return c.json({jobId:P.info.id})});let s=A.object({auditId:A.string(),prompts:A.array(A.string()).max(15).default([])});n.post("/api/visibility",async c=>{let l=s.parse(await c.req.json()),u=t(),h=C(u),b=D(l.auditId);if(!b||b.type!=="domain")return c.json({error:"audit not found"},404);let P=H("ai-visibility",async f=>{let S=await St(h,b,l.prompts,($,x)=>f.emit("progress",$,x));N(S),f.done(S.id)});return c.json({jobId:P.info.id})});let a=A.object({clientId:A.string().min(10),clientSecret:A.string().min(5)});n.post("/api/gsc/credentials",async c=>{let l=a.parse(await c.req.json()),u=t();return u.gsc={clientId:l.clientId.trim(),clientSecret:l.clientSecret.trim()},B(u),c.json({ok:!0})}),n.get("/api/gsc/connect",c=>{let l=t();if(!l.gsc)return c.json({error:"Save your Google OAuth client ID and secret first."},400);let u=`${new URL(c.req.url).origin}/api/gsc/callback`;return c.json({authUrl:xt(l.gsc.clientId,u)})}),n.get("/api/gsc/callback",async c=>{let l=c.req.query("code"),u=t();if(!l||!u.gsc)return c.text("Missing code or GSC configuration",400);let h=`${new URL(c.req.url).origin}/api/gsc/callback`,{refreshToken:b}=await $t(u.gsc.clientId,u.gsc.clientSecret,l,h);return u.gsc.refreshToken=b,B(u),c.redirect("/settings?gsc=connected")}),n.get("/api/gsc/queries",async c=>{let l=c.req.query("domain")??"",u=t();if(!u.gsc?.refreshToken)return c.json({error:"Search Console is not connected"},400);let h=await e(u.gsc),b=Ee(await Ie(h),l);return b?c.json({property:b,rows:await je(h,b)}):c.json({error:`No Search Console property matches ${l}`},404)});let d=A.object({path:A.string().min(1),skipLlm:A.boolean().optional()});n.post("/api/codebase/scan",async c=>{let l=d.parse(await c.req.json()),u=t(),h=C(u),b=H("codebase-scan",async P=>{P.emit("progress",`Round 1: rule scan of ${l.path}`,.1);let f=me(l.path);P.emit("progress",`Detected ${f.framework}; ${f.files.length} source files`,.2);let S=he(f);if(!l.skipLlm){P.emit("progress","Round 2: AI agent exploring the codebase (read-only)",.3);try{let x=await ze(h,f.root,f.framework,S,E=>P.emit("agent",E.kind==="tool"?`[tool] ${E.message}`:E.message));S=[...S,...x],P.emit("progress",`AI auditor added ${x.length} finding(s)`,.7)}catch(x){P.warn("AI audit round failed",x)}}let $=fe(f,S);if(!l.skipLlm){P.emit("progress","Writing the report",.85);try{$.llmReport=await oe(h,{subject:`${f.framework} codebase at ${f.root}`,scorecard:$.scorecard,findings:S,contentSamples:f.files.slice(0,4).map(x=>({location:x.rel,sample:x.content.slice(0,1e3)}))})}catch(x){P.warn("AI report failed",x)}}N($),P.done($.id)});return c.json({jobId:b.info.id})});let m=A.object({reportId:A.string(),findingId:A.string(),allowDirty:A.boolean().optional()});n.post("/api/codebase/fix",async c=>{let l=m.parse(await c.req.json()),u=t(),h=C(u),b=D(l.reportId);if(!b||b.type!=="codebase")return c.json({error:"codebase report not found"},404);let P=b.findings.find(S=>S.id===l.findingId);if(!P)return c.json({error:"finding not found"},404);if(!await wt(b.path))return c.json({error:"Not a git repository \u2014 auto-fix requires git so changes are revertible."},400);if(!l.allowDirty&&!await bt(b.path))return c.json({error:"dirty-tree",detail:"The working tree has uncommitted changes. Commit/stash them, or retry allowing a dirty tree."},409);let f=H("codebase-fix",async S=>{S.emit("progress",`Agent fixing: ${P.title}`,.1);let $=await kt(h,b.path,P,b.framework,x=>S.emit("agent",x.kind==="tool"?`[tool] ${x.message}`:x.message));S.payload=$,S.done()});return c.json({jobId:f.info.id})});let y=A.object({reportId:A.string(),files:A.array(A.string()).min(1)});n.post("/api/codebase/revert",async c=>{let l=y.parse(await c.req.json()),u=D(l.reportId);return!u||u.type!=="codebase"?c.json({error:"codebase report not found"},404):(await vt(u.path,l.files),c.json({ok:!0}))});let p=A.object({auditId:A.string(),keyword:A.string().min(2).max(120),format:A.enum(["standard","vs-competitor","alternatives","best-roundup"]).optional(),competitorName:A.string().max(80).optional()});n.post("/api/briefs",async c=>{let l=p.parse(await c.req.json()),u=t(),h=C(u),b=D(l.auditId);if(!b||b.type!=="domain")return c.json({error:"audit not found"},404);let P=H("content-brief",async f=>{f.emit("progress",`Building ${l.format??"standard"} brief for "${l.keyword}"`,.1);let S=await jt(h,b,l.keyword,$=>f.emit("progress",$,.5),l.format??"standard",l.competitorName);N(S),f.done(S.id)});return c.json({jobId:P.info.id})}),n.get("/api/drift",c=>{let l=c.req.query("before"),u=c.req.query("after");if(!l||!u)return c.json({error:"before and after report ids required"},400);let h=D(l),b=D(u);return!h||h.type!=="domain"||!b||b.type!=="domain"?c.json({error:"both ids must be domain audit reports"},404):c.json({before:{id:h.id,createdAt:h.createdAt,overall:h.scorecard.overall},after:{id:b.id,createdAt:b.createdAt,overall:b.scorecard.overall},domain:b.domain,changes:We(h,b)})}),n.get("/api/jobs/:id",c=>{let l=_e(c.req.param("id"));return l?c.json({...l.info,payload:l.payload}):c.json({error:"not found"},404)}),n.get("/api/jobs/:id/events",c=>{let l=_e(c.req.param("id"));return l?co(c,async u=>{for(let h of l.info.events)await u.writeSSE({data:JSON.stringify(h)});l.info.status==="running"&&await new Promise(h=>{let b=l.subscribe(async P=>{await u.writeSSE({data:JSON.stringify(P)}),(P.kind==="done"||P.kind==="error")&&(b(),h())});u.onAbort(()=>{b(),h()})})}):c.json({error:"not found"},404)}),n.get("/api/reports",c=>c.json(Ye())),n.get("/api/reports/:id",c=>{let l=D(c.req.param("id"));return l?c.json(l):c.json({error:"not found"},404)}),n.delete("/api/reports/:id",c=>c.json({ok:Xe(c.req.param("id"))})),n.get("/api/reports/:id/export",async c=>{let l=D(c.req.param("id"));if(!l)return c.json({error:"not found"},404);let u=await Qe(l);return c.body(new Uint8Array(u),200,{"content-type":"application/pdf","content-disposition":`attachment; filename="${Ve(l)}"`})});let g=po();return n.get("*",c=>{if(!g)return c.text("UI not built. Run `npm run build:ui`.",500);let l=c.req.path==="/"?"/index.html":c.req.path,u=q.join(g,q.normalize(l));if(u.startsWith(g)&&ne.existsSync(u)&&ne.statSync(u).isFile()){let h=q.extname(u);return c.body(ne.readFileSync(u),200,{"content-type":lo[h]??"application/octet-stream"})}return c.html(ne.readFileSync(q.join(g,"index.html"),"utf8"))}),n.onError((c,l)=>l.json({error:W(c.message)},500)),n}function Ft(n){let e=uo();return new Promise((t,r)=>{Dt({fetch:e.fetch,port:n,hostname:"127.0.0.1"},i=>t(i.port)).on("error",i=>{i.code==="EADDRINUSE"?Dt({fetch:e.fetch,port:0,hostname:"127.0.0.1"},a=>t(a.port)).on("error",r):r(i)})})}var J=new ho;function re(n,e){let t=te(ee({source:n,error:e}));return t?`Full error report saved to ${t}. Send that file to get help.`:""}async function Gt(n,e){let{renderReportPdf:t,reportFilename:r}=await import("./exportPdf-26EHUONL.js"),o=mo.resolve(typeof e=="string"?e:r(n));process.stderr.write(v.dim(` Rendering PDF\u2026
57
+ `)),go.writeFileSync(o,await t(n)),process.stderr.write(` PDF saved to ${v.cyan(o)}
58
+ `)}function qt(n,e,t){let r=i=>i>=80?v.green:i>=55?v.yellow:v.red;console.log(""),console.log(v.bold(` ${n}`)),console.log(` Overall: ${r(e.overall)(v.bold(`${e.overall}/100`))}`);for(let i of e.categories){let s="\u2588".repeat(Math.round(i.score/5)).padEnd(20,"\u2591");console.log(` ${i.label.padEnd(18)} ${r(i.score)(s)} ${String(i.score).padStart(3)}`)}let o={critical:0,warning:0,info:0};for(let i of t)i.severity in o&&o[i.severity]++;console.log(`
59
+ ${v.red(`${o.critical} critical`)} ${v.yellow(`${o.warning} warnings`)} ${v.dim(`${o.info} suggestions`)}`);for(let i of t.filter(s=>s.severity==="critical"))console.log(` ${v.red("\u2717")} ${i.title} ${v.dim(`(${i.occurrences}\xD7)`)}`);console.log("")}J.name("pulse-seo").description("SEO & AEO auditor for domains and codebases, powered by Claude or OpenAI").version(z);J.command("start",{isDefault:!0}).description("Start the web UI (default command)").option("-p, --port <port>","port for the local server","4173").option("--no-open","don't open the browser automatically").action(async n=>{let e=await Q();de(e,{needsAi:!0,mode:"warn"});let r=`http://localhost:${await Ft(Number(n.port))}`;console.log(`
60
+ ${v.bold("Pulse Media SEO/AEO Audit")} running at ${v.cyan(r)}
61
+ ${v.dim("Press Ctrl+C to stop.")}
62
+ `),n.open&&await fo(r).catch(()=>{})});J.command("login").description("(Re)configure provider, authentication and model").action(async()=>{await $e(M())});J.command("audit <domain>").description("Audit a domain from the terminal (no UI)").option("--pages <n>","max pages to crawl","25").option("--no-ai","skip the AI narrative report").option("--render","render pages with headless Chrome (JS execution)").option("--no-perf","skip Core Web Vitals measurement").option("--json","output the full report as JSON").option("--pdf [file]","also save the report as a PDF").action(async(n,e)=>{let t=await Q();de(t,{needsAi:e.ai,mode:"strict"});let r=C(t),o=await Z(r,n,{maxPages:Number(e.pages),timeoutMs:t.crawl.timeoutMs,skipLlm:!e.ai,render:e.render??t.crawl.render,measurePerformance:e.perf&&t.performance,psiApiKey:t.psiApiKey,cache:(await import("./store-DIYBNZTJ.js")).getStore(),onProgress:i=>{e.json||process.stderr.write(`\r\x1B[2K ${v.dim(i.slice(0,100))}`)},onIssue:(i,s)=>{process.stderr.write(v.yellow(`\r\x1B[2K ${i}: ${s instanceof Error?s.message:s}
63
+ `)),process.stderr.write(v.dim(` ${re(`CLI: ${i}`,s)}
64
+ `))}});if(e.json||process.stderr.write("\r\x1B[2K"),N(o),e.pdf&&await Gt(o,e.pdf),e.json){console.log(JSON.stringify(o,null,2));return}if(qt(o.domain,o.scorecard,o.findings),o.llmReport){console.log(` ${v.bold("Summary:")} ${o.llmReport.summary}
65
+ `);for(let i of o.llmReport.actionPlan.slice(0,5))console.log(` ${v.cyan(`${i.priority}.`)} ${i.title} ${v.dim(`[impact: ${i.impact}, effort: ${i.effort}]`)}`);console.log("")}console.log(v.dim(" Full report saved \u2014 open the UI with `pulse-seo` to explore it.\n"))});J.command("scan [path]").description("Audit a codebase from the terminal (no UI)").option("--no-ai","skip the AI narrative report").option("--json","output the full report as JSON").option("--pdf [file]","also save the report as a PDF").action(async(n,e)=>{let t=await Q();de(t,{needsAi:e.ai,mode:"strict"});let r=me(n??process.cwd()),o=he(r),i=C(t);if(e.ai){process.stderr.write(v.dim(` Round 2: AI agent exploring the codebase (read-only)\u2026
66
+ `));try{let{aiCodebaseAudit:a}=await import("./aiAudit-WYRYXY3F.js"),d=await a(i,r.root,r.framework,o,m=>{m.kind==="tool"&&process.stderr.write(v.dim(` ${m.message}
67
+ `))});o=[...o,...d]}catch(a){process.stderr.write(v.yellow(` AI audit round failed: ${a instanceof Error?a.message:a}
68
+ `)),process.stderr.write(v.dim(` ${re("CLI: AI audit round",a)}
69
+ `))}}let s=fe(r,o);if(e.ai){let{buildLlmReport:a}=await import("./report-SFEY33ZL.js");process.stderr.write(v.dim(` Writing the narrative report and action plan (may take a minute)\u2026
70
+ `));try{s.llmReport=await a(i,{subject:`${r.framework} codebase at ${r.root}`,scorecard:s.scorecard,findings:o,contentSamples:r.files.slice(0,4).map(d=>({location:d.rel,sample:d.content.slice(0,1e3)}))})}catch(d){process.stderr.write(v.yellow(` AI report failed: ${d instanceof Error?d.message:d}
71
+ `)),process.stderr.write(v.dim(` ${re("CLI: AI report",d)}
72
+ `))}}if(N(s),e.pdf&&await Gt(s,e.pdf),e.json){console.log(JSON.stringify(s,null,2));return}qt(`${r.root} (${r.framework}, ${r.files.length} files)`,s.scorecard,s.findings),console.log(v.dim(" Open the UI with `pulse-seo` to review findings and apply AI fixes.\n"))});J.command("drift <domain>").description("Re-audit a domain, compare with the previous run, and exit 1 on regressions (cron/CI friendly)").option("--pages <n>","max pages to crawl","15").option("--fail-on <severity>","exit non-zero at this drift severity or worse: critical|warning|info","critical").option("--json","output drift as JSON").action(async(n,e)=>{let t=await Q(),{normalizeDomain:r}=await import("./crawler-LJDICWL6.js"),{compareAudits:o,driftExitCode:i}=await import("./drift-WE75HT5C.js"),{getStore:s}=await import("./store-DIYBNZTJ.js"),a=r(n),d=s().findLatestDomainReport(a),m=await Z(null,n,{maxPages:Number(e.pages),timeoutMs:t.crawl.timeoutMs,skipLlm:!0,render:t.crawl.render,onProgress:g=>{e.json||process.stderr.write(`\r\x1B[2K ${v.dim(g.slice(0,100))}`)},onIssue:(g,c)=>{process.stderr.write(v.yellow(`\r\x1B[2K ${g}: ${c instanceof Error?c.message:c}
73
+ `)),process.stderr.write(v.dim(` ${re(`CLI: ${g}`,c)}
74
+ `))}});if(e.json||process.stderr.write("\r\x1B[2K"),N(m),!d||d.type!=="domain"){let g=`Baseline created for ${a} (${m.pagesCrawled} pages, score ${m.scorecard.overall}). Run again after changes to detect drift.`;console.log(e.json?JSON.stringify({baseline:!0,reportId:m.id}):` ${g}`);return}let y=o(d,m);if(e.json)console.log(JSON.stringify({domain:a,before:d.id,after:m.id,changes:y},null,2));else{console.log(`
75
+ ${v.bold(a)} ${d.scorecard.overall} \u2192 ${m.scorecard.overall} ${v.dim(`(baseline ${new Date(d.createdAt).toLocaleString()})`)}
76
+ `),y.length===0&&console.log(v.green(` No SEO-relevant drift detected.
77
+ `));for(let g of y){let c=g.severity==="critical"?v.red:g.severity==="warning"?v.yellow:v.dim;console.log(` ${c(`[${g.severity}]`)} ${g.field} ${v.dim(g.url)}
78
+ ${v.red(`- ${g.before.slice(0,90)}`)}
79
+ ${v.green(`+ ${g.after.slice(0,90)}`)}`)}y.length>0&&console.log("")}let p=i(y,["critical","warning","info"].includes(e.failOn)?e.failOn:"critical");process.exit(p)});J.command("config").description("Show the current configuration").action(()=>{let n=M();if(!n){console.log(`Not configured yet \u2014 run ${v.cyan("pulse-seo login")}.`);return}console.log(` Config file: ${qe}`),console.log(` Provider: ${n.provider}`),console.log(` Model: ${n.model??"(provider default)"}`),console.log(` Auth: ${C(n).authStatus().detail}`),console.log(` Crawl limit: ${n.crawl.maxPages} pages`)});J.parseAsync().catch(n=>{console.error(v.red(`
69
80
  ${n instanceof Error?n.message:n}
81
+ `));let e=re(`CLI: ${process.argv.slice(2,3).join(" ")||"start"}`,n);e&&console.error(v.dim(` ${e}
70
82
  `)),process.exit(1)});