# PDF Report Generator — Full Rebuild Prompt CRITICAL GATE — TOKEN-FIRST (NON-NEGOTIABLE) ──────────────────────────────────────────────────────────────────────────────── Before any actions (scaffold, npm install, npm run dev/build, coding, analysis), the agent MUST: 1. Resolve token in strict order: a) MCP secret source b) runtime env: SE_RANKING_DATA_API_TOKEN c) project .env.local d) sibling/local env files (../*/.env.local, backups) 2. If token is not found: - Stop immediately. - Print this machine-readable blocking line first: Missing SE_RANKING_DATA_API_TOKEN in .env.local and MCP secret source returned no token. - Then print a human-friendly user message (required): SE_RANKING_DATA_API_TOKEN is missing. Please provide one of the following: 1) The SE_RANKING_DATA_API_TOKEN value, or 2) A path to a local env file where it already exists. How to get the token in SE Ranking: 1. Open SE Ranking app 2. In the left sidebar, go to API 3. Open Dashboard 4. Open API Keys 5. Copy Data API Key 6. Share the key here in chat (or share a path to a local env file where it already exists). - Do not run any other commands until user provides token value or env file path. 3. If token is found: - Write/update project .env.local. - Never print token values in chat, logs, terminal output, or UI. - Continue only after token gate passes. 4. Compliance checkpoints (mandatory): - First agent status message: Starting token gate check. - Second status message: Token gate status: PASS or Token gate status: BLOCKED. Any violation means the task is considered incomplete. --- ## Goal A Next.js web app where an SEO account manager enters a client domain, country, date range, and optional notes. The app fetches live SEO data, generates a multi-page client-ready PDF, and streams it back to the browser for download. No database. No login. Designed to run locally or on a self-hosted server. --- ## Prerequisites Check (mandatory before coding) Before creating files, verify required tools in the target environment: - `node` - `npm` - `python3` - `pip3` Run: ```bash command -v node command -v npm command -v python3 command -v pip3 ``` Rules (must follow): 1. If a tool exists, continue without asking. 2. If a tool is missing, stop and ask the user to install exactly that missing tool. 3. Provide exact install commands for macOS (Homebrew), Ubuntu/Debian, and Windows. 4. After the user confirms installation, re-run checks and continue automatically. 5. Never claim the app is runnable unless `npm install` and `npm run dev` were attempted successfully. ## Missing Dependency Behavior If any required dependency is missing: 1. Explicitly report what is missing. 2. Ask permission for automatic installation. 3. For automatic installation, trigger elevated command approval (native in-app popup), then run install commands yourself. 4. If user declines automatic install, provide exact manual commands and wait. 5. Re-check dependencies and continue automatically once installed. 6. Never stop at missing dependencies if automatic installation was approved. 7. Do not ask for confirmation before routine read-only checks or standard project commands (`npm install`, `npm run build`, `npm run dev`) when permissions already allow execution. ## Elevated Permission Flow (mandatory) When installation may require higher privileges or unrestricted network: 1. Use the built-in system approval popup (approve / approve+remember), not only plain chat text. 2. After approval, execute installation commands immediately. 3. If the command fails due to missing `sudo` rights (or admin-only constraints), clearly report it and provide one copy-paste command for the user to run locally with admin password. 4. After the user confirms local admin step is done, resume automatically from dependency re-check. ## WeasyPrint System Libraries (macOS) For macOS, besides `pip3 install -r requirements.txt`, ensure native libraries are installed for WeasyPrint runtime (`glib`, `pango`, `cairo`, `gdk-pixbuf`, `libffi`). Preferred auto flow: 1. Install Homebrew (if missing) via elevated approval. 2. Install libraries: `brew install glib pango cairo gdk-pixbuf libffi`. 3. Validate with a smoke test: `python3 scripts/generate_report.py /tmp/test-data.json /tmp/output.pdf`. 4. If macOS still shows `cannot load library 'libgobject-2.0-0'`, run smoke test with Homebrew library path: `DYLD_LIBRARY_PATH=/opt/homebrew/lib:${DYLD_LIBRARY_PATH:-} python3 scripts/generate_report.py /tmp/test-data.json /tmp/output.pdf` 5. In app runtime, ensure Python process includes `DYLD_LIBRARY_PATH=/opt/homebrew/lib` when spawning WeasyPrint. If Homebrew install fails because admin password is required, ask user to run Homebrew install locally, then continue automatically with `brew install ...` and verification. ## Local Admin Password Fallback (applies to any user) If a step requires admin password entry, the agent must not ask the user to share the password in chat. Required behavior: 1. Tell the user to open a terminal on their own computer first: - macOS/Linux: Terminal - Windows: PowerShell (or Command Prompt if explicitly needed) 2. Clearly state that password entry must be done locally by the user. 3. Explain that during `sudo` prompt on macOS/Linux, password input is hidden (no characters, no `*`) and this is normal; user should type password blindly and press Enter. 4. Provide exactly one copy-paste command block for the user's terminal. 5. Ask the user to reply with `done` after running it. 6. Immediately continue automatically after `done` (re-check deps, complete setup, run verification). Default macOS block to output when Homebrew is missing and WeasyPrint libs are needed: ```bash /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" eval "$(/opt/homebrew/bin/brew shellenv)" brew install glib pango cairo gdk-pixbuf libffi ``` During Homebrew install, the user may see: `Press RETURN/ENTER to continue or any other key to abort` Agent instruction for this step: 1. Tell the user to press `Enter` to continue installation. 2. Tell the user not to close the terminal while installation is running. 3. After completion, ask user to reply with `done` and then continue automatically. If all dependencies are present, do not ask unnecessary install questions. --- Note: a run usually consumes about 10,500 SE Ranking Data API credits. ## Stack - **Next.js 15 (App Router, TypeScript, Tailwind CSS)** - **React 19** - **WeasyPrint** — Python HTML-to-PDF renderer - **SE Ranking Data API** — live SEO data source - **Server-Sent Events** — progress streaming from API route to UI - **uuid** npm package — job IDs for temp file linking - No database — temp files in OS `tmpdir()`, cleaned up after download --- ## Environment variables `.env.local` (provide `.env.example` with empty placeholders): ``` SE_RANKING_DATA_API_TOKEN= # SE Ranking Data API token — Authorization: Token PYTHON_BIN= # Optional — path to python3 binary, e.g. /opt/homebrew/bin/python3.14 ``` ## Token-First Execution Order (mandatory) 1. Follow the top-level section `CRITICAL GATE — TOKEN-FIRST (NON-NEGOTIABLE)` as the single source of truth for token lookup order and blocked behavior. 2. Do not run `npm run build`, `npm run dev`, scaffolding, coding, or report generation until token gate passes. 3. After token write/update, restart dev server and only then continue runtime verification. ## Env Validation Gate (mandatory) Before running generation, verify required env values are present and non-empty: - `SE_RANKING_DATA_API_TOKEN` (required) Rules: 1. If token is missing, do not run data-fetch pipeline. 2. Use the same blocking output contract defined in `CRITICAL GATE — TOKEN-FIRST (NON-NEGOTIABLE)` (machine-readable line first, then user-facing guidance). 3. Ask user to add token, then restart dev server automatically if needed. 4. Retry generation only after env check passes. ## HOW TO GET SE RANKING DATA API TOKEN (MANDATORY USER GUIDANCE) If token is missing and agent must ask user, include this exact guidance: 1. Open SE Ranking app. 2. In the left sidebar, go to API. 3. Open Dashboard. 4. Open API Keys. 5. Copy Data API Key. 6. Share the key here in chat (or share a path to a local env file where it already exists). Never print existing token values found in env/MCP. ## LANGUAGE LOCALIZATION POLICY (MANDATORY) All user-facing guidance must be written in the user's language, while product names and technical terms remain in English. Exception (must stay in Latin exactly as written): - SE Ranking app - API - Dashboard - API Keys - Data API Key For the token guidance block, translate every step/action into the user's language, while keeping the five terms above unchanged. Required token guidance format: Use the exact 6-step token guidance from `HOW TO GET SE RANKING DATA API TOKEN (MANDATORY USER GUIDANCE)`. Write actions in the user's language, but keep these product terms in English exactly: `SE Ranking app`, `API`, `Dashboard`, `API Keys`, `Data API Key`. ## Token Source Policy (mandatory) Treat SE Ranking token as an explicit app credential. 1. Do not assume MCP app integrations automatically expose `SE_RANKING_DATA_API_TOKEN` to Next.js runtime. 2. Check token in this order: - MCP secret source (if attached and accessible) - process env (`SE_RANKING_DATA_API_TOKEN`) - project `.env.local` 3. If MCP is missing or secret source returns no token, ask user immediately (before build/run) for one of: - provide token value for `.env.local` - point to an existing local `.env.local` file to import from 4. If user provides path to existing env file, copy token automatically and continue. 5. After setting token, restart dev server and continue report generation flow automatically. 6. Never print token value in chat, logs, terminal output, or UI. ## Auto Token Bootstrap (mandatory) Before final handoff URL, ensure token is actually present for runtime: 1. If `SE_RANKING_DATA_API_TOKEN` is missing in current project `.env.local`, auto-search common sibling sources (for example `../ai-visibility-checker/.env.local`, backup clones, or user-provided env path). 2. If a valid token line is found, copy only the key-value line into current project `.env.local` and preserve existing non-secret settings. 3. Never print token value in chat, logs, or UI. 4. Restart dev server after writing `.env.local`. 5. Do not return final app link until token check passes or user explicitly declines token setup. Fast-search guardrails (mandatory): 1. Do not run broad recursive scans like `find ..` without depth/file limits. 2. Search sibling env files with bounded scope only: - known sibling paths first (for example `../ai-visibility-checker/.env.local`, `../audit-deck-builder/.env.local`, `../pdf-report-generator/.env.local`) - then max depth 2, filename `.env.local` only. 3. Keep token discovery check under 10 seconds total; if not found quickly, stop search and ask user for token value or env path. 4. If a scan command hangs or exceeds the time budget, abort it and continue with manual token request flow immediately. ## Known Token Lookup Issue (mandatory) - Known issue: unbounded sibling env search may hang and slow token gate. - Required mitigation: use bounded token lookup only (known sibling paths first, then maxdepth=2 `.env.local` scan, strict time budget). - If timeout is reached, stop scan and ask user for token value or env file path instead of waiting longer. --- ## Base Path Requirement The app must run under: `http://localhost:3000/pdf-report-generator` Implementation requirements: 1. Set `basePath: "/pdf-report-generator"` in `next.config.ts`. 2. Set `env.NEXT_PUBLIC_BASE_PATH = "/pdf-report-generator"` in `next.config.ts`. 3. Ensure all client links and API calls use the base path (no hardcoded bare `/api/...` links). 4. If root route is used, redirect to `/report` (do not duplicate basePath in redirect target). --- ## File structure ``` / ├── app/ │ ├── layout.tsx # Root layout — dark navy gradient background │ ├── page.tsx # Root redirect or landing (optional) │ ├── globals.css # Tailwind + spin + progress-pulse keyframes │ └── report/ │ └── page.tsx # Report UI — form, progress spinner, download button ├── app/api/ │ ├── report/route.ts # GET — SSE streaming report generation │ └── report-download/route.ts # GET — serves the PDF and cleans up temp files ├── lib/ │ ├── types.ts # All TypeScript interfaces │ └── seranking.ts # SE Ranking API client + report-specific functions ├── scripts/ │ └── generate_report.py # Python HTML-to-PDF builder (WeasyPrint) ├── requirements.txt # weasyprint ├── package.json └── .env.example ``` --- ## SE Ranking API rules (critical — do not skip) These rules apply to every call in `lib/seranking.ts`: 1. All API calls must be **sequential**. No `Promise.all` / parallel fetches. 2. Enforce an **800 ms sleep** before every API call. 3. On HTTP **429**: exponential backoff — wait 1 s, 2 s, 4 s, 8 s (up to 4 retries). 4. Auth header: `Authorization: Token ` 5. Base URL: `https://api.seranking.com/v1` 6. Throw a readable error on non-2xx that includes the status code and response body. --- ## Type definitions — `lib/types.ts` ```typescript export interface DomainOverview { organic: { keywords_count: number traffic_sum: number price_sum?: number keywords_new_count?: number keywords_up_count?: number keywords_down_count?: number keywords_lost_count?: number top1_5?: number top6_10?: number top11_20?: number top21_50?: number top51_100?: number } adv?: { keywords_count: number; traffic_sum: number; price_sum?: number } } export interface KeywordRow { keyword: string position: number prev_pos: number | null // null = new volume: number // field is "volume" NOT "search_volume" traffic?: number traffic_percent?: number url?: string cpc?: number difficulty?: number } export interface DomainKeywordsResponse { total: number data: KeywordRow[] } export interface BacklinksSummary { backlinks: number refdomains: number // field is "refdomains" NOT "referring_domains" nofollow_backlinks?: number dofollow_backlinks?: number dofollow_refdomains?: number inlink_rank?: number domain_inlink_rank?: number } // ─── Monthly Report types ───────────────────────────────────────────────────── export interface KeywordMover { keyword: string position: number prev_pos: number change: number // positive = improved (lower position number = better rank) volume: number url?: string } export interface BacklinkChange { new_backlinks: number lost_backlinks: number new_refdomains: number lost_refdomains: number } export interface AIEngineRow { engine: string // 'ai-overview' | 'ai-mode' | 'chatgpt' | 'gemini' | 'perplexity' mentions?: number // brand_presence.current citations?: number // link_presence.current avg_position?: number opp_traffic?: number } export interface AITrendPoint { date: string brand_presence: number | null } export interface SiteAuditReport { score: number pages_crawled?: number issues?: { errors: number; warnings: number; notices: number } } export interface MonthlyReportData { domain: string country: string date_from: string date_to: string generated_at: string notes: string overview: DomainOverview | null keywords: { total: number top10_count: number up_count: number down_count: number new_count: number lost_count: number movers_up: KeywordMover[] movers_down: KeywordMover[] } | null backlinks: BacklinksSummary | null backlink_changes: BacklinkChange | null ai_visibility: { current: AIEngineRow[] trend: AITrendPoint[] } | null site_audit: SiteAuditReport | null improved: string[] needs_attention: string[] next_priorities: string[] errors: Record // per-step fetch errors (non-fatal) } ``` --- ## SE Ranking API client — `lib/seranking.ts` Implement all of the following exported functions. Include the `sleep`, `fetchWithBackoff`, and `apiFetch` helpers first. ### Core helpers ```typescript const BASE = 'https://api.seranking.com/v1' const sleep = (ms: number) => new Promise(res => setTimeout(res, ms)) async function fetchWithBackoff(url, options, retries = 4, baseDelay = 1000): Promise // On 429: wait baseDelay * 2^attempt ms, retry up to retries times async function apiFetch(url: string, options: RequestInit = {}): Promise // 1. await sleep(800) // 2. Read SE_RANKING_DATA_API_TOKEN from process.env, throw if missing // 3. Set Authorization: Token header // 4. Call fetchWithBackoff // 5. On non-2xx: throw Error with status + body text // 6. Return res.json() ``` ### getDomainOverview(domain, source) ``` GET /v1/domain/overview/db?source=&domain=&with_subdomains=1 Response: { organic: [...], adv: [...] } CRITICAL: organic is returned as an ARRAY — always normalise to a single object: if (Array.isArray(arr) && arr.length > 0) return arr[0] ``` ### getDomainKeywords(domain, source, limit = 20) ``` GET /v1/domain/keywords?source=&domain=&type=organic&limit=&order_field=traffic_percent&order_type=desc CRITICAL: use order_field + order_type (NOT order_by — that returns 400) Response: plain JSON array (not wrapped) Return: { total: data.length, data: KeywordRow[] } ``` ### getBacklinksSummary(domain) ``` GET /v1/backlinks/summary?target=&mode=host Response: { summary: [BacklinksSummary] } — unwrap: return summary[0] Field name is "refdomains" NOT "referring_domains" ``` ### getKeywordMovers(domain, source, limit = 50) Calls `getDomainKeywords(domain, source, limit)` then derives movers from `prev_pos` vs `position`: ```typescript change = prev_pos - position // positive = improved rank movers_up: change > 0, sorted by change desc, top 10 movers_down: change < 0, sorted by change asc, top 10 ``` ### getNewLostBacklinks(domain, dateFrom, dateTo) Two separate endpoints, both return a daily array wrapped in a named key: ``` GET /v1/backlinks/history/count?target=&mode=host&date_from=&date_to= → { new_lost_backlinks_count: [{ date, new, lost }, ...] } GET /v1/backlinks/refdomains/history/count?target=&mode=host&date_from=&date_to= → { new_lost_refdomains_count: [{ date, new, lost }, ...] } ``` Helper `sumDailyArray(data)`: grab the first array value from the response object (regardless of key name) and sum `row.new` and `row.lost` across all days. Returns `BacklinkChange`: `{ new_backlinks, lost_backlinks, new_refdomains, lost_refdomains }`. **Note:** These two fetches can run in `Promise.all` since they hit different endpoints and the 800 ms rule is per-call — but be conservative and run them sequentially if you see rate limit errors. ### getAITrendData(domain, country) For each of the 5 engines `['ai-overview', 'ai-mode', 'chatgpt', 'gemini', 'perplexity']`: ``` GET /v1/ai-search/overview?target=&source=&engine=&scope=base_domain CRITICAL: engine parameter is REQUIRED — omitting it returns 400. Response structure (nested — NOT flat): { summary: { brand_presence: { current: N }, link_presence: { current: N }, average_position: { current: N }, ai_opportunity_traffic: { current: N } }, time_series: { brand_presence: [{ date, value }, ...], ... } } ``` Collect `current` AIEngineRow for each engine. For `trend`: take `time_series.brand_presence` (or fall back to `link_presence`) from the first engine that returns it. Take the last 8 data points and map to `AITrendPoint[]`. Returns: `{ current: AIEngineRow[], trend: AITrendPoint[] }` ### getDomainOverviewHistory(domain, source, dateFrom, dateTo) ``` GET /v1/domain/overview/history?source=&domain=&date_from=&date_to=&with_subdomains=1 Returns array of { date, keywords_count, traffic_sum } points. Normalise field names: keywords_count ?? organic_keywords, traffic_sum ?? organic_traffic ``` --- ## API route — `app/api/report/route.ts` `GET /api/report?domain=&country=&date_from=&date_to=¬es=` Set `export const dynamic = 'force-dynamic'` and `export const maxDuration = 300`. Returns a **Server-Sent Events** stream. Each event: `data: \n\n` Event shapes: ```typescript { type: 'progress', step: string, percent: number } { type: 'done', jobId: string } { type: 'error', message: string } { type: 'api_errors', errors: Record } ``` ### Pipeline (send a progress event after each step): ``` Step 1 — 10% — getDomainOverview(domain, country) Step 2 — 20% — getDomainOverviewHistory(domain, country, dateFrom, dateTo) Step 3 — 33% — getKeywordMovers(domain, country, 100) Derive keywordsData: top10_count = (overview.organic.top1_5 ?? 0) + (overview.organic.top6_10 ?? 0) up_count = overview.organic.keywords_up_count ?? movers_up.length down_count = overview.organic.keywords_down_count ?? movers_down.length Step 4 — 48% — getBacklinksSummary(domain) Step 5 — 58% — getNewLostBacklinks(domain, dateFrom, dateTo) Step 6 — 70% — getAITrendData(domain, country) Step 7 — 82% — buildNarrative(partialData) → { improved, needs_attention, next_priorities } Step 8 — 90% — Write JSON to tmpdir, spawn python3 generate_report.py Step 9 — 100% — Write .report.meta.json to tmpdir, send done event with jobId ``` Each step is in a `try/catch` — on error, store to `errors` dict and continue. After all data is gathered, send an `api_errors` event if `errors` is non-empty. ### buildNarrative(data) Generates bullet lists purely from the data — no LLM calls. **improved** (up to 6 bullets): - Keywords that moved into top 3 (from `movers_up` where position ≤ 3) - Keywords with big gains (position > 3 and change ≥ 5, up to 3) - New keywords count if `new_count > 0` - Improved keywords count if `up_count > 0` - Gained referring domains if `new_refdomains > 0` - Site health score ≥ 80 - AI mentions count if `aiMentions > 0` **needs_attention** (up to 6 bullets): - Big drops (|change| ≥ 5, up to 3 keywords) - Lost keywords count - More declines than improvements flag - Lost refdomains > 2 - Site health score < 70 - Critical site errors > 10 - No AI visibility **next_priorities** (exactly 3): - Top dropped keyword + action - Lost refdomains investigation (or thin backlink profile if none lost) - Site audit score action, or AI content if score is fine ### Spawning the Python script ```typescript const jobId = uuidv4() const dataFile = join(tmpdir(), `${jobId}-report-data.json`) const pdfFile = join(tmpdir(), `${jobId}.pdf`) await writeFile(dataFile, JSON.stringify(reportData, null, 2), 'utf-8') const python = process.env.PYTHON_BIN ?? '/opt/homebrew/bin/python3.14' const dyld = process.env.DYLD_LIBRARY_PATH ?? '' const proc = spawn(python, [scriptPath, dataFile, pdfFile], { env: { ...process.env, DYLD_LIBRARY_PATH: dyld ? `/opt/homebrew/lib:${dyld}` : '/opt/homebrew/lib', }, }) // Collect stderr; reject on non-zero exit code await readFile(pdfFile) // verify it was created await unlink(dataFile) // clean up input const metaFile = join(tmpdir(), `${jobId}.report.meta.json`) await writeFile(metaFile, JSON.stringify({ domain, pdfFile, dateFrom, dateTo, createdAt: Date.now() })) ``` --- ## Download route — `app/api/report-download/route.ts` `GET /api/report-download?jobId=` 1. Validate jobId is a UUID (regex: `/^[0-9a-f-]{36}$/i`) 2. Read `${jobId}.report.meta.json` from `tmpdir()` 3. Read the PDF file path from meta 4. Return PDF as `application/pdf` with `Content-Disposition: attachment` 5. Filename format: `{safeDomain}-report-{dateFrom}_{dateTo}.pdf` 6. After 5 seconds, clean up both the PDF and meta files --- ## UI — `app/report/page.tsx` `'use client'` component. Dark navy-to-indigo gradient background. Centered white card with three states: idle/error → generating → done. ### Countries list (value = SE Ranking source code) ``` us, gb, ca, au, de, fr, es, it, nl, br, in, jp, mx, pl, sg, za ``` ### Date presets ```typescript type DatePreset = 'current_month' | 'last_month' | 'last_30' | 'last_60' | 'last_90' | 'custom' ``` `getPresetDates(preset)` returns `{ from: string, to: string }` in `YYYY-MM-DD` format. Initialise to `last_month` on mount. When a preset is chosen, auto-fill the From/To date inputs. If the user edits the date inputs manually, switch preset to `'custom'`. ### Form fields - Client Domain (required text input, strips `https://` and paths) - Country / Market (select, default `us`) - Reporting Period: - Preset dropdown (current_month, last_month, last_30, last_60, last_90, custom) - From / To date inputs (always visible, auto-filled by preset, editable) - Account Manager Notes (optional textarea, maxLength 800, shows char count) - Submit button: "Generate Report" - Near the submit button, show this helper note: "Note: a run usually consumes about 10,500 SE Ranking Data API credits." ### Generating state - Spinning SVG circle with `progressPct` number in the centre - Progress bar (`progressPct` width, transitions smoothly) - `progressStep` label with `progress-pulse` animation - Connect to `/api/report?` via `EventSource` - Handle `progress`, `done`, `api_errors`, `error` events ### Done state - Green checkmark circle - "Download PDF" button → triggers `/api/report-download?jobId=` - Collapsible `
` warning if any API errors were reported - "Generate another report" reset link ### Error state - Red error box with message - Form re-appears so user can retry --- ## Python PDF builder — `scripts/generate_report.py` Called as: `python3 generate_report.py ` Reads the JSON file, builds a complete HTML document with inline CSS, then renders it to PDF with WeasyPrint. ### Helper functions ```python def fmt_num(n, suffix='') # None → '—', ≥1M → '1.2M', ≥1K → '42.3K', else int string def fmt_date(iso: str) -> str # ISO timestamp → '17 April 2026' def period_label(date_from, date_to) -> str # '1 Apr 2026 – 30 Apr 2026' def delta_badge(value, invert=False) -> str # Returns HTML # None or 0 → neutral '—' badge # invert=False (higher is better): positive = green badge, negative = red badge # invert=True (lower is better): positive = red badge (it's a loss) # Always pass the raw positive count with invert=True for "lost" metrics def build_ai_chart(trend: list) -> str # Generates an inline SVG bar chart from AITrendPoint list # Each bar = brand_presence value, x-label = 'Apr 26' format def engine_label(engine: str) -> str # 'ai-overview' → 'AI Overview', 'ai-mode' → 'AI Mode', etc. ``` ### PDF pages (A4, 210mm × 297mm) | Page | Title | Key content | |------|-------|-------------| | 1 | Cover | Domain (large), period, "Monthly SEO Performance Report" eyebrow, "Prepared by your account manager", generated date, indigo badge | | 2 | Performance Snapshot | 2×2 KPI grid: Organic Traffic / Keywords in Top 10 / Referring Domains / AI Search Mentions; Keyword Movement Breakdown (4 stats: Improved / Declined / New / Lost); AI Engine Breakdown table | | 3 | Performance Analysis | Two-column split: "What improved this month" (green bullets) / "What needs attention" (red bullets) | | 4 | AI Visibility & Keyword Movement | AI brand presence SVG bar chart; side-by-side Keyword Movers tables (up/down, max 10 each) | | 5 | Next Month Priorities + Notes | Numbered priority items; Account Manager Notes card (indigo-to-green gradient) | ### KPI tile — Referring Domains Shows `refdomains` as main value with delta badges: - Gained: `delta_badge(new_refdomains, invert=False)` → green if positive - Lost: `delta_badge(lost_refdomains, invert=True)` → red if positive ### Keywords in Top 10 tile - Value: `top10_count` (or fallback to `total`) - Sub: `delta_badge(new_count)` new · `delta_badge(lost_count, invert=True)` lost ### Mover rows Columns: Keyword | Now | Before | Change (▲/▼ delta, coloured green/red) ### CSS design system (inline in Python) ``` Cover background: linear-gradient(160deg, #0F172A 0%, #1E1B4B 60%, #0F172A 100%) Page background: white Primary accent: #6366F1 (indigo) KPI tiles: background #F8FAFC, border #E2E8F0, border-radius 10px Badge up: background #D1FAE5, color #065F46 Badge down: background #FEE2E2, color #991B1B Badge neutral: background #F1F5F9, color #64748B Green: #059669 Red: #DC2626 Blue: #4F46E5 Gray: #64748B Body text: #334155 Muted: #94A3B8 Headings: #0F172A ``` All content pages have a header divider (`border-bottom: 1.5px solid #E2E8F0`) with page title (left) and period string (right). --- ## `app/globals.css` ```css @tailwind base; @tailwind components; @tailwind utilities; @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); body { font-family: 'Inter', system-ui, sans-serif; } @keyframes progress-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } } .progress-pulse { animation: progress-pulse 1.5s ease-in-out infinite; } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .spin { animation: spin 1s linear infinite; } ``` --- ## `requirements.txt` ``` weasyprint ``` --- ## Known API gotchas (bake these in from the start) 1. `getDomainKeywords`: use `order_field=traffic_percent&order_type=desc` — NOT `order_by`. 2. Domain overview `organic` field comes back as an **array `[{}]`**, not an object. Always normalise: `if (Array.isArray(arr)) return arr[0]`. 3. BacklinksSummary field is `refdomains` not `referring_domains`. 4. KeywordRow volume field is `volume` not `search_volume`. 5. `/ai-search/overview` requires `engine=` parameter. Omitting it returns `{"error":["engine parameter is required"]}`. Make one call per engine. 6. `/ai-search/overview` response is deeply nested: `raw.summary.brand_presence.current` — not flat top-level fields. 7. AI API returns current-period counts; SE Ranking platform shows cumulative all-time. Expect API numbers to be lower — this is correct, not a bug. 8. New/lost backlinks endpoints return **daily arrays wrapped in a named key**, not a flat object. Use the `sumDailyArray` helper to sum `row.new` and `row.lost` regardless of key name. 9. All SE Ranking API calls must be sequential with 800 ms delay. The API rate-limits parallel requests from the same token. ## Build-to-Ready Delivery (mandatory) When the user asks to build from this instruction file, the agent must complete the full flow end-to-end (not stop at code changes): 1. Create/update the app code. 2. Install dependencies. 3. Run build checks (`npm run build`) and fix blocking issues. 4. Start dev server (`npm run dev`) by itself. 5. Ensure the app is reachable at `http://localhost:3000/pdf-report-generator`. 6. Return the final clickable localhost root link exactly as `http://localhost:3000/pdf-report-generator` (do not return `/report` as the final handoff link). If dev server fails with sandbox port binding errors (for example `listen EPERM ... 0.0.0.0:3000`): 1. Automatically request elevated command approval. 2. Re-run `npm run dev` with elevated permissions. 3. Continue until server is ready, then provide the final link. Do not offload these steps to the user unless local password entry is strictly required. ## Handoff URL Formatting Safety (mandatory) To avoid broken links in chat/UI handoff: 1. Always provide the final URL as a plain raw URL on its own line first (no Markdown wrapper). 2. Do not include `[]`, `()`, backticks, trailing punctuation, or mixed Markdown patterns around the raw URL. 3. If you also provide a clickable Markdown link, it must be on a separate line after the raw URL and must exactly match it. 4. When app runs on a fallback port, return the actual running URL (for example `http://localhost:3001/pdf-report-generator`) instead of hardcoding port 3000. 5. Before handoff, sanity-check that the final URL string contains only one scheme prefix (`http://`) and no encoded bracket fragments such as `%5D(`. 6. Add one short user-facing line before the raw URL that tells the user to open it in the browser. Keep the example in this instruction file in English, but in real responses always use the user's language. Example (instruction-file English only): "Here is the link — open it in your browser:" ## Dev Runtime Stability (404 / Hydration) If user reports intermittent 404 or hydration warning in dev mode: 1. Verify base path behavior: - `GET /pdf-report-generator` should redirect to `/pdf-report-generator/report` - `GET /pdf-report-generator/report` should return 200 2. Restart dev server after config changes (`next.config.ts`, redirects, basePath) to clear stale routing state. 3. If URL contains duplicated segment like `/pdf-report-generator/pdf-report-generator/report`, instruct user to open the root handoff link again: `http://localhost:3000/pdf-report-generator`. 4. For hydration mismatch caused by browser extensions injecting attributes (for example Grammarly `data-gr-*`), treat as non-blocking dev noise and add `suppressHydrationWarning` on root `html`/`body` in `app/layout.tsx`. 5. After fix/restart, provide only the final root link: `http://localhost:3000/pdf-report-generator`. 6. If Next.js runtime shows `Cannot find module './.js'` from `.next/server/webpack-runtime.js`, treat it as stale build cache corruption and do this automatically: - stop dev server - reset `.next` (rename to timestamped backup or clean it) - start `npm run dev` again - verify `/pdf-report-generator` -> 307 and `/pdf-report-generator/report` -> 200 before handoff. 7. Always translate technical errors into user-friendly guidance. Show one short message like: - `The app hit a temporary local build issue. Please return to this AI chat and ask: "Fix local runtime error and restart the app".` Then continue auto-recovery when the user asks. ## Connectivity Check Policy (mandatory) To reduce unnecessary approval popups: 1. Do not run pre-check or final-check `curl` probes if dev server has already started successfully and user has not reported issues. 2. Return final app URL immediately after successful server start. 3. Run `curl`/HTTP verification only when: - user explicitly asks for verification, or - there are symptoms (404/500/runtime errors, unstable routing, failed handoff). 4. If verification is needed, prefer already-approved command prefixes and avoid duplicate confirmation prompts. ## Approval Popup Minimization (mandatory) 1. Do not ask optional pre-flight questions like "verify URL before rebuild?". 2. Ask user only when a command truly needs elevated/system approval or when a required secret is missing. 3. If a package or system dependency is already installed, do not run install commands again. 4. For missing dependencies, install them automatically when permissions allow; ask the user only if elevated/system approval is strictly required by the platform. 5. If the platform shows an approval dialog anyway, instruct user to choose "Yes, and don't ask again" for that safe command prefix. ## Port 3000 Conflict Policy (mandatory) If dev server cannot bind to `localhost:3000` because port 3000 is already in use: 1. Do not ask to stop the old process by default. 2. Automatically run on the next free port and return that exact working URL. 3. Ask to stop the old process only if the user explicitly requires strict port 3000. 4. If user approves stopping the old process, stop it and restart app on port 3000. 5. Prefer "allow and remember" for this action to reduce repeated prompts in future runs. ## UI Action Layout Guardrails (mandatory) For completion/success states and CTA blocks: 1. Primary button and secondary action link must not overlap or appear as one inline row unless explicitly designed that way. 2. Primary CTA should be in its own centered block container (`flex justify-center` or equivalent). 3. Secondary action should render in a separate row (`block` + centered alignment). 4. Validate final UI on both desktop and mobile widths before handoff. 5. If overlap is detected, adjust layout classes first (spacing/display/container) before changing copy or removing actions. --- ## Setup instructions ```bash # 1. Install JS dependencies npm install # 2. Install Python dependencies pip3 install -r requirements.txt # or: pip3 install weasyprint # 3. Configure environment cp .env.example .env.local # Add SE_RANKING_DATA_API_TOKEN= # Optionally set PYTHON_BIN= to the path of your python3 binary # 4. Start the dev server npm run dev # 5. Open the report UI open http://localhost:3000/pdf-report-generator/report ``` ### Testing the Python script directly ```bash # Create a minimal test-data.json in /tmp then run: python3 scripts/generate_report.py /tmp/test-data.json /tmp/output.pdf open /tmp/output.pdf ``` ### Production deploy notes - Requires a server where Python 3 + WeasyPrint can be installed (Vercel does not support this natively — use a VPS, Railway, Render, or a custom Docker image). - Set `SE_RANKING_DATA_API_TOKEN` in environment variables. - `maxDuration = 300` requires a runtime that supports 5-minute function timeouts. --- ## Branding rules - No mention of SE Ranking or any data provider in the PDF output. - Cover badge says "Monthly SEO Report". - Footer / meta shows only: `{domain} · {period}` and `Generated {date}`. - "Prepared by your account manager" on the cover.