agentsclimarketplace

Geo analysis

Skill popiliadam/platinum-seo-engine/skills/discovery/geo-analysis

Use when: kullanıcı "geo analysis", "GEO", "AEO", "answer engine optimization", "generative engine optimization", "LLM mentions", "ChatGPT'de görünüyor muyum", "AI Overviews", "Perplexity citation", "AI search visibility", "LLM'lerde markam geçiyor mu" der ya da /pseo-geo-analysis çağırır. Also use when: aktif projenin keyword listesi config'te tanımlı; budget pre-flight PASS; project.config display_name (project_name) set; cross-source D-03 normalize hem cited_url hem serp_url üstünde uygulanır; LLM visibility (mentions across models) + SERP organic baseline cross-ref'lenip AEO_NEEDED/SERP_GAP/AEO_HEALTHY/ABSENT etiketi üretilir. Do not use when: tek-URL on-page audit (on-page-audit), keyword volume çekme (dfs-pull), GSC quick-wins (quick-wins), cannibalization analizi (cannibalization), competitor crawl (scrapling-ops) — ayrı skill'ler. project.config display_name yokken çağırma (DURUR #10); budget aşılmışsa fallback YASAK (DURUR #2).From its SKILL.md

Install
npx -y skills add popiliadam/platinum-seo-engine --skill geo-analysis

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

SKILL.md

22.3 KB, as published. Nobody here has run it

geo-analysis — discovery skill (Phase 7 Wave 2, paid-MCP, staging-only)

10-step protocol. Steps map 1:1 to workflow_runner invocations + the spec §16.5 8-step MCP discipline + §16.8 budget pre-flight + §11.6 LLM mentions tooling. Raw JSON drift recovery is mandatory: every DFS response is dropped into inbox/dfs/ before any transform runs, so a transform bug never costs us the upstream payload (which is paid; re-fetch costs credits).

This skill follows the convention authority of skills/ingestion/dfs-pull/SKILL.md (Phase 6 — staging-only routing, _normalize_dfs_response shape adapter, paid-MCP budget pre-flight) and the Wave 1 D-03 cross-source pattern from skills/discovery/on-page-audit/SKILL.md (URL canonicalization on BOTH sides of the cross-source join). Deviate only with an ADR.

Cross-source semantics: DFS llm_mentions_search supplies LLM-answer visibility (mentions across gpt-4 / claude-sonnet-4 / gemini-* / llama-* / others); DFS serp_organic_live_advanced supplies the traditional SERP baseline. Each query gets a derived geo_signal row that classifies the AEO/GEO posture into one of four labels:

GapConditionAction heuristic
AEO_NEEDEDSERP top-3 present AND brand mentioned in 0 LLMs (or <50%)write AEO-formatted answer content (FAQ + schema)
SERP_GAPBrand mentioned in ≥1 LLM AND no SERP top-3 placementboost SERP rank — internal links + content depth
AEO_HEALTHYBrand mentioned in ≥50% of queried models AND SERP top-3monitor — already cited by LLMs
ABSENTNeither LLM mention nor SERP top-3research priority — pillar / cluster gap

Output sort order is gap severity (AEO_NEEDED first, AEO_HEALTHY last) so the operator triages the most actionable gaps in the report.

Inputs (frontmatter contract)

NameTypeDefaultNotes
project_slugstring—Required. Resolves projects/{slug}/master.xlsx.
queriesarray—Required. Query list driving the paid LLM-mentions + SERP calls.
llm_modelsarray—LLM model fan-out; absent → DFS server-side default.
location_codeinteger2792DFS Turkey baseline.
language_codestring"tr"DFS Turkish baseline.

workspace_root is resolved via PSEO_WORKSPACE_ROOT env or explicit test override (mirrors workflow_runner / events_writer).

project_name is resolved from projects/{slug}/project.config.json display_name field. The transform raises ProjectNameMissingError (DURUR #10) when this field is empty/missing — our_brand_mentioned substring matching cannot run without it.

Outputs (artifacts produced — D-003 staging-only)

  • projects/{slug}/_state/staging/geo_analysis_llm_mentions_{date}_{slug}.json — per-mention rows (6 cols: query, model, mention_text, cited_url, our_brand_mentioned, our_url_cited).
  • projects/{slug}/_state/staging/geo_analysis_serp_organic_{date}_{slug}.json — per-position rows (4 cols: query, serp_position, serp_url, serp_title).
  • projects/{slug}/_state/staging/geo_analysis_signals_{date}_{slug}.json — cross-ref derived rows (7 cols: query, llm_visibility_score, our_brand_mentioned_count, our_url_cited_count, serp_top_3_present, geo_gap, action).
  • projects/{slug}/outputs/reports/{date}-geo-analysis.md — human summary (top AEO_NEEDED gaps, visibility histogram, action queue).
  • projects/{slug}/_state/events.jsonl — event_kind=provenance entries with cost.credits for each DFS call (source.kind= dataforseo_mcp, target_excel_sheet=null — staging-only).
  • projects/{slug}/inbox/dfs/{date}-llm_mentions_search-{slug}.json — raw LLM mentions payload (drift recovery, paid).
  • projects/{slug}/inbox/dfs/{date}-serp_organic-{slug}.json — raw SERP payload (drift recovery, paid).

Note: geo-analysis does NOT write to master.xlsx. The three staging tables feed downstream skills (Phase 8+ cluster-map for opportunity projection; monthly-report for AEO trend dashboards). This mirrors the dfs-pull staging-only paterni (D-003 resolution).

D-03 URL canonicalization (cross-source invariant)

Every URL passing through this skill is normalized via scripts.discovery.geo_analysis_transform._normalize_url. The function is idempotent: _normalize_url(_normalize_url(u)) == _normalize_url(u). Rules: lowercase scheme+host, IDN→punycode, strip default ports (:80/:443), strip trailing slash (root excluded), drop fragment, drop tracking params (utm_, gclid, fbclid, mc_, msclkid), sort remaining query keys.

Canonicalization is applied to BOTH cited_url (LLM payload, when present) AND serp_url (SERP payload) before any cross-source check (our_url_cited, serp_top_3_present). The rule mirrors schemas/cross-sheet-invariants.json#D-03 and the helper logic from scripts.discovery.on_page_audit_transform._normalize_url.

Empty / scheme-less input tolerance: unlike on-page-audit (which raises on empty), geo-analysis returns "" for missing or scheme-less URLs because LLM citations sometimes reference a brand name without a URL. The empty string never matches our_url_cited or serp_top_3_present, so the downstream signal is correctly False.

10-Step Body Protocol

Each step name must match the steps[*].name passed to workflow_runner.create_run. Names are stable identifiers across runs.

Step 1 — preflight_budget (§16.8, MANDATORY for paid MCP)

estimate = query_count × (5 + 1)   # llm_mentions + serp_organic per-query

Run scripts.budget.check_budget against the project's 24h running total. Exit code 0 → proceed. Exit code 1 → DURUR #2 (BudgetExceededError); skill exits awaiting_approval and never silently downgrades.

from scripts.discovery import geo_analysis_transform as ga
from scripts.state import workflow_runner
handle = workflow_runner.create_run(
    skill="geo-analysis",
    project_slug=project_slug,
    steps=[
        {"name": "preflight_budget"},
        {"name": "fetch_llm_mentions"},
        {"name": "fetch_serp_organic"},
        {"name": "transform"},
        {"name": "request_approval"},
        {"name": "write_staging"},
        {"name": "render_report"},
    ],
)
workflow_runner.start_step(handle.run_id, 0, project_slug=project_slug)
estimate = ga.estimate_credits(len(queries))
envelope = ga.preflight_budget(
    estimated_credits=estimate,
    project_config_path=project_root / "project.config.json",
    events_path=project_root / "_state" / "events.jsonl",
)
workflow_runner.finish_step(handle.run_id, 0, project_slug=project_slug,
                            output_ref=str(envelope))

Step 2 — fetch_llm_mentions (MCP §16.5 step 3 — raw inbox FIRST)

raw_llm = mcp__dataforseo__ai_optimization_llm_mentions_search(
    keywords=queries,
    models=llm_models,        # optional fan-out; absent → DFS default
    location_code=location_code,
    language_code=language_code,
)
inbox_llm = (
    workspace_root / "projects" / project_slug
    / "inbox" / "dfs"
    / f"{today.isoformat()}-llm_mentions_search-{project_slug}.json"
)
inbox_llm.parent.mkdir(parents=True, exist_ok=True)
inbox_llm.write_text(json.dumps(raw_llm, ensure_ascii=False, indent=2))

Step 3 — fetch_serp_organic (paid baseline, raw inbox FIRST)

raw_serp = mcp__dataforseo__serp_organic_live_advanced(
    keywords=queries,
    location_code=location_code,
    language_code=language_code,
    depth=10,                  # cover top-10 organic positions
)
inbox_serp = (
    workspace_root / "projects" / project_slug
    / "inbox" / "dfs"
    / f"{today.isoformat()}-serp_organic-{project_slug}.json"
)
inbox_serp.write_text(json.dumps(raw_serp, ensure_ascii=False, indent=2))

Step 4 — transform

Pure compute via scripts/discovery/geo_analysis_transform.py:

python3 scripts/discovery/geo_analysis_transform.py \
    --raw-llm-mentions inbox/dfs/{date}-llm_mentions_search-{slug}.json \
    --raw-serp         inbox/dfs/{date}-serp_organic-{slug}.json \
    --project-name     "{display_name from project.config}" \
    --project-url-root "{domain from project.config}" \
    --project-slug     {project_slug} \
    --output-dir       _state/transform/{run_id}/

Produces three JSON arrays (llm_mentions, serp_organic, geo_signals). URL canonicalization (D-03) is applied here on BOTH the LLM cited_url and the SERP serp_url before any cross-ref, not at fetch time, so the raw inbox copies are byte-faithful to the upstream payloads. The transform is idempotent: same inputs → byte-identical output (modulo meta.fetched_at).

The transform calls _normalize_dfs_response (IMPORTED from scripts.ingestion.dfs_pull — never copied) on both raw payloads first, so the dataforseo-mcp-server REST envelope vs flat wrapper drift is tolerated uniformly.

mention_text is truncated at 500 chars (geo-analysis brief mandate) before any matching to keep downstream artifacts bounded.

Step 5 — request_approval (skill EXIT awaiting_approval)

workflow_runner.request_approval(
    handle.run_id, project_slug=project_slug,
    approver="user",
    subject=f"{len(queries)} sorgu için GEO/AEO analizi hesaplandı, "
            f"_state/staging/'a yazalım mı?",
    step_index=4,
)
# Skill exits here. The user replies in a fresh session; resume below.

Step 6 — Resume (approve → continue)

workflow_runner.approve(handle.run_id, project_slug=project_slug,
                        approver="user")

Step 7 — write_staging (atomic, schema-validated, NO Excel write)

Three staging JSON files — one per derived table. Each file follows the canonical naming geo_analysis_{table}_{date}_{slug}.json under projects/{slug}/_state/staging/. NO transaction.append here; staging-only.

import json
from pathlib import Path
date = today.isoformat()
staging_dir = workspace_root / "projects" / project_slug / "_state" / "staging"
staging_dir.mkdir(parents=True, exist_ok=True)

for table in ("llm_mentions", "serp_organic", "signals"):
    payload_key = "geo_signals" if table == "signals" else table
    out_path = staging_dir / f"geo_analysis_{table}_{date}_{project_slug}.json"
    out_path.write_text(
        json.dumps(result[payload_key], ensure_ascii=False, indent=2),
        encoding="utf-8",
    )

Step 8 — render_report

render_template.py templates/reports/geo-analysis.template.md data.json → outputs/reports/{date}-geo-analysis.md. Variables: $project_slug, $date, $query_count, $credits_used, $gap_aeo_needed, $gap_serp_gap, $gap_aeo_healthy, $gap_absent, $top_query, $top_action.

Step 9 — Provenance event

Two events — one per upstream paid call. Each carries cost.credits; target_excel_sheet=null because output is staging-only.

from scripts.state import events_writer
# run_id=None auto-allocates race-free inside the append flock; reuse the
# returned id to group both provenance events under one geo-analysis run.
geo_run = events_writer.append_provenance(
    project_id=project_slug,
    source={"kind": "dataforseo_mcp", "mcp_server": "dataforseo",
            "mcp_tool": "dataforseo__ai_optimization_llm_mentions_search",
            "response_bytes": len(json.dumps(raw_llm))},
    operation="ingest",
    target_excel_sheet=None,    # staging-only — Phase 8+ projects to Excel
    rows_written=result["meta"]["llm_mention_row_count"],
    cost={"provider": "dataforseo",
          "credits": float(query_count) * 5.0,
          "budget_key": "project.config.dataforseo.budget_credits_per_day"},
)
events_writer.append_provenance(
    project_id=project_slug,
    run_id=geo_run.run_id,    # same run as the llm_mentions event above
    source={"kind": "dataforseo_mcp", "mcp_server": "dataforseo",
            "mcp_tool": "dataforseo__serp_organic_live_advanced",
            "response_bytes": len(json.dumps(raw_serp))},
    operation="ingest",
    target_excel_sheet=None,
    rows_written=result["meta"]["serp_organic_row_count"],
    cost={"provider": "dataforseo",
          "credits": float(query_count) * 1.0,
          "budget_key": "project.config.dataforseo.budget_credits_per_day"},
)

Step 10 — complete

workflow_runner.complete(handle.run_id, project_slug=project_slug, outputs={
    # F5: outputs.* must be STRING-TYPED.
    "llm_mention_rows":    str(result["meta"]["llm_mention_row_count"]),
    "serp_organic_rows":   str(result["meta"]["serp_organic_row_count"]),
    "geo_signal_rows":     str(result["meta"]["geo_signal_row_count"]),
    "credits_used":        str(estimate),
    "report_path":         str(report_path),
    "raw_llm":             str(inbox_llm),
    "raw_serp":            str(inbox_serp),
})

llm_visibility_score formula

llm_visibility_score = brand_mentioned_models / total_models_queried   (0.0–1.0)

Counted at the model level (NOT per-mention) — a single model mentioning the brand 5 times still contributes 1 to the numerator. The denominator is the count of distinct model values seen for the query in the response, so a partial-fan-out response (some models timed out upstream) is reported truthfully.

AEO_HEALTHY_THRESHOLD = 0.5 — brand must surface in at least half of queried models for the gap label to be AEO_HEALTHY. Score below the threshold with SERP top-3 still falls under AEO_NEEDED (the AEO surface area is the bigger lever when the brand isn't already a default LLM citation).

DURUR conditions (10)

Stop and flag the manager — do not patch, do not fall back.

  1. mcp__dataforseo__ai_optimization_llm_mentions_search returns auth/network/scope error.
  2. check_budget.py --check exit 1 (or preflight_budget() raises BudgetExceededError) → STOP, awaiting_approval; never silently skip the paid call.
  3. _normalize_dfs_response raises ValueError (REST envelope AND flat wrapper both fail to parse) → STOP, upstream shape drift.
  4. _state/staging/ path cannot be created (workspace path missing or read-only).
  5. inbox/dfs/ path cannot be created.
  6. PSEO_WORKSPACE_ROOT env unset and no explicit workspace_root passed to workflow_runner / events_writer.
  7. workflow_runner.create_run fails schema validation (schemas/workflow-run.schema.json).
  8. D-03 cross-source URL normalization mismatch between LLM cited_url and SERP serp_url for any cross-ref derivation. Default behaviour: the geo signal row is computed normally (since our_url_cited and serp_top_3_present use project root prefix matching, not paired URL equality, the mismatch surfaces as False rather than silently joining the wrong URL). This is a documented design choice — geo-analysis does NOT do per-row URL joins; it does presence checks against project_url_root, so a www-vs-non-www host drift between LLM citations and SERP rows is handled by D-03 normalization on both sides without a join. Strict mode is unnecessary here (unlike on-page-audit DURUR #4 which DOES join paired URLs).
  9. llm_mentions_search response shape drift — model field renamed/missing, OR mention_text/text missing on a per-mention row → LlmMentionsDriftError (DURUR — surface to manager, refuse to silently emit empty rows from a broken upstream).
  10. project.config display_name empty/missing — geo-analysis needs the project name for our_brand_mentioned substring matching (case-insensitive). Raise ProjectNameMissingError and surface "configure project_name first" to the operator.

Cross-references

  • Schemas: schemas/skill-frontmatter.schema.json (this frontmatter), schemas/events.schema.json (source.kind=dataforseo_mcp, target_excel_sheet=null for staging-only ops, cost.credits field), schemas/cross-sheet-invariants.json#D-03 (URL canonicalization), schemas/dataforseo-endpoint-mapping.schema.json (DFS contract; cost.credits_per_call for ai_optimization_llm_mentions_search + serp_organic_live_advanced).
  • Cross-modules (IMPORT-only): scripts/state/workflow_runner.py, scripts/state/events_writer.py, scripts/budget/check_budget.py, scripts/reporting/render_template.py, scripts/ingestion/dfs_pull.py (_normalize_dfs_response IMPORTED — never copied; the REST envelope vs flat wrapper contract lives in dfs_pull and any drift fix propagates to all DFS-consuming skills via this single import). Note: scripts/excel/transaction.py is NOT imported (staging-only — D-003).
  • Transform: scripts/discovery/geo_analysis_transform.py (_normalize_url, transform, estimate_credits, preflight_budget, GeoAnalysisError, LlmMentionsDriftError, BudgetExceededError, ProjectNameMissingError, LLM_MENTIONS_COLUMNS, SERP_ORGANIC_COLUMNS, GEO_SIGNALS_COLUMNS).
  • Tests: tests/skills/test_geo_analysis.py (≥15 cases incl. URL normalization, gap-label heuristic coverage, IMPORT discipline lock, budget pre-flight integration, drift DURUR).
  • Template: templates/reports/geo-analysis.template.md.
  • Phase 8+ consumers: cluster-map (opportunity projection), monthly-report (AEO trend dashboards) — both consume the three staging JSON files.

Discipline checklist

  • TODO/fallback YASAK — every DURUR raises explicitly; no silent degrade. Empty cited_url returns "" (documented design choice).
  • Schema-first — frontmatter validates against schemas/skill-frontmatter.schema.json Draft 7. Budget block uses ONLY uses_paid_mcp + estimated_credits (Q-W-A4-01: NO _per_call / _per_url).
  • Plugin-agnostik — no slug literals; project_slug flows through. Phase 6 forbidden-slug regex (per worker brief) verified absent on all 3 files (SKILL.md + transform + test).
  • Budget pre-flight integration — scripts.budget.check_budget invoked at step 1 BEFORE any paid DFS call (paid-MCP gate).
  • Append-only — all events via events_writer; raw inbox JSON is write-once per (date, slug, tool); staging files are overwrite-idempotent (same input → byte-stable).
  • IMPORT discipline (W-B4 brief) — _normalize_dfs_response IMPORTED from scripts.ingestion.dfs_pull (never copied). Locked by tests/skills/test_geo_analysis.py:: test_normalize_helper_imported_not_copied identity check.
  • Cross-module IMPORT discipline — workflow_runner / events_writer / check_budget / render_template are imported, never modified from this skill. scripts.excel.transaction is NOT imported (staging-only — D-003).
  • D-03 URL canonicalization applied on BOTH cited_url (LLM) and serp_url (SERP) before any presence check. Idempotency held.
  • F5: outputs.* values are STRING-TYPED (artifact paths or stringified counts), never raw ints.

Keep looking

Skills are one crate of 628,853. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.