agentsclimarketplace

Tech audit

Skill popiliadam/platinum-seo-engine/skills/discovery/tech-audit

Use when: kullanıcı "teknik SEO audit", "lighthouse skoru", "core web vitals", "LCP / CLS / FCP düşür", "page speed", "performans audit", "title meta H1 kontrol", "structured data eksik mi", "tech seo issues", "site hızı problemleri" der ya da /pseo-tech-audit çağırır. DFS HEAVY ingestion (paid MCP); budget pre-flight ZORUNLU. Also use when: aktif projenin URL listesi config'te tanımlı; Phase 7 Wave 1 discovery; on_page_lighthouse + on_page_content_parsing tier çalıştırılacak; bulgular master.xlsx#tech_seo'ya schema-locked 6-kolon formatta yazılacak. Do not use when: GSC veri ingestion (gsc-pull), SF csv ingestion (sf-import), keyword research (dfs-pull), quick-wins fırsat skoru, cannibalization, content decay analizi gerekiyor — ayrı skill'ler. Master.xlsx yokken çağırma; init-project önce çalışmalı (DURUR #6). Budget pre-flight FAIL ise asla bypass etme — workflow_runner awaiting_approval state'ine route et (DURUR #1).From its SKILL.md

Install
npx -y skills add popiliadam/platinum-seo-engine --skill tech-audit

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

SKILL.md

28.5 KB, as published. Nobody here has run it

tech-audit — discovery skill (Phase 7 Wave 1, DFS HEAVY)

10-step protocol. Steps map 1:1 to workflow_runner invocations + the spec §16.5 8-step MCP discipline + §16.8 budget pre-flight + ADR-016 cost-tracking. 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 — and Lighthouse is the most expensive on_page endpoint at up to 10 credits per URL).

This skill mirrors the Phase 6 dfs-pull 10-step shape verbatim and adapts the Phase 5 quick-wins discovery aggregation pattern to the 6-column tech_seo schema. Reuse the structure; deviate only with an ADR.

Inputs (frontmatter contract)

NameTypeDefaultNotes
project_slugstring—Required. Resolves projects/{slug}/master.xlsx.
urlsarray—Required. URL list — drives credit cost AND DoS cap.
url_capinteger50DoS prevention; raise via project-config or arg if needed.
devicestring"desktop"desktop or mobile Lighthouse profile.

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

Outputs (artifacts produced)

  • projects/{slug}/master.xlsx#tech_seo — schema-locked 6 cols (issue_category | detail | affected_urls | impact | resolution | priority). impact is severityEnum (CRITICAL/HIGH/MEDIUM/LOW); priority is P0/P1/P2 (CRITICAL+HIGH→P0, MEDIUM→P1, LOW→P2).
  • projects/{slug}/outputs/reports/{date}-tech-audit.md — human-readable summary (perf score histogram + per-category top issues).
  • projects/{slug}/_state/events.jsonl — event_kind=provenance entries (source.kind=dataforseo_mcp, target_excel_sheet=tech_seo, cost.credits populated per ADR-016 — every DFS call adds an event).
  • projects/{slug}/inbox/dfs/{date}-lighthouse-{slug}.json — raw Lighthouse payload (drift recovery; per-URL bundle).
  • projects/{slug}/inbox/dfs/{date}-content_parsing-{slug}.json — raw on_page_content_parsing payload.

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 + ADR-016, MANDATORY before any DFS call)

The skill MUST estimate credits BEFORE any paid MCP call and run scripts/budget/check_budget.py against the project's 24h running total. Estimate formula:

estimated_credits = url_count * 10  (lighthouse, conservative MAX of 5-10)
                  + url_count * 3   (content_parsing, conservative MAX of 2-3)
                  = url_count * 13

Invocation (subprocess; the script is the SSoT for budget arithmetic per ADR-016):

from scripts.discovery import tech_audit_transform
estimate = tech_audit_transform.estimate_credits(len(urls))
envelope = tech_audit_transform.preflight_budget(
    estimated_credits=estimate,
    project_config_path=project_root / "project.config.json",
    events_path=project_root / "_state" / "events.jsonl",
)

Exit code 0 (or envelope["exceeded"] is False) → proceed. Exit code 1 OR projected_used > budget → BudgetExceededError. The skill MUST then route the workflow to awaiting_approval:

workflow_runner.request_approval(
    handle.run_id, project_slug=project_slug,
    approver="user",
    subject=f"Budget pre-flight FAIL: estimated={estimate} credits would "
            f"exceed daily cap. Approve override?",
    step_index=0,
)
# Skill exits here. Manager surface → §10.

NEVER silently downgrade the estimate or skip the check (DURUR #1).

Step 2 — create_run

Open a workflow run shell. The state file lives at projects/{slug}/_state/workflows/{run_id}.json (ADR-021).

from scripts.state import workflow_runner
handle = workflow_runner.create_run(
    skill="tech-audit",
    project_slug=project_slug,
    steps=[
        {"name": "preflight_budget"},
        {"name": "fetch_lighthouse"},
        {"name": "fetch_content_parsing"},
        {"name": "transform"},
        {"name": "request_approval"},
        {"name": "write_excel"},
        {"name": "render_report"},
    ],
)

Step 3 — fetch_lighthouse (MCP §16.5 step 3 — raw inbox first)

Per-URL fan-out OR batched call (depends on wrapper); persist the FULL response set into inbox/dfs/ BEFORE any transform runs. ADR-016 requires per-call cost tracking, so each Lighthouse call also emits a provenance event with cost.credits populated:

workflow_runner.start_step(handle.run_id, 1, project_slug=project_slug)
raw_lighthouse = mcp__dataforseo__on_page_lighthouse(
    target=urls,
    for_mobile=(device == "mobile"),
)
inbox_path = (
    workspace_root / "projects" / project_slug
    / "inbox" / "dfs"
    / f"{today.isoformat()}-lighthouse-{project_slug}.json"
)
inbox_path.parent.mkdir(parents=True, exist_ok=True)
inbox_path.write_text(json.dumps(raw_lighthouse, ensure_ascii=False, indent=2))

events_writer.append_provenance(
    project_id=project_slug,
    source={"kind": "dataforseo_mcp", "mcp_server": "dataforseo",
            "mcp_tool": "dataforseo__on_page_lighthouse",
            "response_bytes": len(json.dumps(raw_lighthouse))},
    operation="ingest",
    target_excel_sheet=None,             # ingest: no Excel write yet
    rows_written=0,
    cost={"provider": "dataforseo",
          "credits": float(len(urls) * 10),   # conservative MAX
          "budget_key": "project.config.dataforseo.budget_credits_per_day"},
)
workflow_runner.finish_step(handle.run_id, 1, project_slug=project_slug,
                            output_ref=str(inbox_path))

If the wrapper auth/networking fails → DURUR #2.

Step 4 — fetch_content_parsing

Same shape as step 3, against mcp__dataforseo__on_page_content_parsing. Per-call cost = len(urls) * 3 credits (conservative MAX). Persist to inbox/dfs/{date}-content_parsing-{slug}.json and emit a provenance event with cost.credits populated.

Step 5 — transform (pure compute, severityEnum strict)

python3 scripts/discovery/tech_audit_transform.py \
    --lighthouse      inbox/dfs/{date}-lighthouse-{slug}.json \
    --content-parsing inbox/dfs/{date}-content_parsing-{slug}.json \
    --url-cap         50 \
    --output-dir      _state/transform/{run_id}/

The transform applies the _normalize_dfs_response shape adapter (REST envelope OR flat wrapper — same pattern as dfs_pull.py), validates each item against the upstream schema (DURUR #4 / #5 on drift), then runs the per-URL findings rules:

SignalThresholdSeverityCategory
Performance score< 50HIGHPerformance
LCP> 2.5sMEDIUMPerformance
FCP> 1.8sLOWPerformance
TBT (responsiveness, INP proxy)> 600msHIGHPerformance
TBT (responsiveness, INP proxy)200–600msMEDIUMPerformance
CLS> 0.1MEDIUMLayout Stability
Title length> 60 charsLOWMeta Tags
Meta description missingemptyMEDIUMMeta Tags
Meta description length> 160 charsMEDIUMMeta Tags
H1 missingcount == 0MEDIUMMeta Tags
Multiple H1 (house-style)count > 1LOWMeta Tags
Structured data absentcommercial pageLOWStructured Data

Findings are aggregated per issue_category; impact = max severity across that category's findings; priority = P0/P1/P2 mapping. affected_urls is comma-joined (capped at ~480 chars; collapses to "N URLs (sample: ...)" past the cap).

Responsiveness — TBT is a lab proxy for INP, not field data (I3). Web Vitals 2024 retired FID in favour of INP (Interaction to Next Paint). This audit reads Lighthouse Total Blocking Time (TBT) from on_page_lighthouse, which is a lab proxy for INP — it is NOT the field INP number (which requires real-user CrUX data). Finding text says so explicitly and never reports a fabricated INP value. CrUX field-data integration is deferred (out of scope here): wiring on_page_lighthouse field-data or the CrUX API to surface real-user INP/LCP/CLS percentiles is a follow-up enhancement, tracked separately. Multiple-H1 findings are LOW (house-style/structure hygiene), not a Google ranking defect.

Step 6 — request_approval (skill EXIT awaiting_approval)

workflow_runner.request_approval(
    handle.run_id, project_slug=project_slug,
    approver="user",
    subject=f"{len(rows)} tech_seo finding kategorisi tespit edildi, "
            f"master.xlsx#tech_seo'ya yazalım mı?",
    step_index=4,
)
# Skill exits here. The user replies in a fresh session; resume below.

Step 7 — Resume (approve → continue)

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

Step 8 — write_excel (atomic, schema-validated)

One committer.commit call — the orchestrator's single idempotent commit path (whole-block transaction.replace from the schema's data_start_row, so re-running the step never duplicates rows on the tech_seo snapshot sheet). Schema validation (RowSchemaError → DURUR #8) happens inside the transaction; the transform also runs a defensive _validate_row pass before returning, so drift is caught twice.

from scripts.orchestration import committer
committer.commit(
    workspace_root/"projects"/project_slug/"master.xlsx",
    "tech_seo",
    tech_seo_rows,
    run_id=handle.run_id,
    project_slug=project_slug,
    writer="tech-audit",
)

Step 9 — render_report + final provenance event

render_template.py templates/reports/tech-audit.template.md data.json → outputs/reports/{date}-tech-audit.md.

Final provenance event (Excel-write phase) — links the project_excel operation to the tech_seo target sheet and carries the SUM of credits billed across both DFS calls (per ADR-016 the cost field MUST be populated):

from scripts.state import events_writer
events_writer.append_provenance(
    project_id=project_slug,
    source={"kind": "dataforseo_mcp", "mcp_server": "dataforseo",
            "mcp_tool": "dataforseo__on_page_lighthouse+content_parsing",
            "response_bytes": len(json.dumps(raw_lighthouse))
                              + len(json.dumps(raw_content_parsing))},
    operation="project_excel",
    target_excel_sheet="tech_seo",
    rows_written=len(tech_seo_rows),
    cost={"provider": "dataforseo",
          "credits": float(len(urls) * 13),
          "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.
    "tech_seo_rows": str(len(tech_seo_rows)),
    "lighthouse_url_count": str(meta["lighthouse_url_count"]),
    "content_parsing_url_count": str(meta["content_parsing_url_count"]),
    "credits_used": str(len(urls) * 13),
    "report_path": str(report_path),
    "raw_jsons": ";".join([str(lh_inbox), str(cp_inbox)]),
})

SF MCP Live Mode (Optional, use_sf_mcp_live=true — D-SF-11)

Default: use_sf_mcp_live=false — file-based ingestion path is the canonical contract (1184+ pytest regression baseline). MCP is augmentation, not replacement. The flag is opt-in only and gated by a client.health() preflight; on probe failure the run AMBER-warns and continues file-based (never hard-fail per R9 mitigation).

When use_sf_mcp_live=true, the SKILL body branches at Step 5 (transform) to optionally enrich the tech_seo rows with the live SF "Issues Overview" report before the transform aggregates per issue_category.

This branch was rewritten (AC-13) against the REAL SF MCP API — the same class of fix as the sf-crawl-orchestrator Step-5 export dispatch (landed in a714e43, live-proven on the demo-aluminum 1822-URL crawl). The engine canonical issues_overview_report is NOT a Screaming Frog identifier: it maps via sf_crawl_orchestrator.SF_EXPORT_DISPATCH to the real sf_generate_report tool with category="Issues Overview" + export_type="CSV". There is NO crawl_id, report_name, or save_report arg; the report runs on the currently-loaded crawl and is written to file_path (relative to the SF allowed base directory). The real SF REJECTS inline output >100KB with a tool error — so file_path (write-to-disk) is the canonical export path (OQ-FILEPATH-EXPORTS), not a non-existent truncated flag. issues_overview is small (~43 rows on demo-aluminum) so it would fit inline, but file_path is the safe canonical path regardless and matches the orchestrator dispatch contract.

The sf_list_crawls result comes back through SfMcpClient.call_tool as the MCP content envelope {"isError":false,"content":[{"text":"<JSON>"}]}, so the crawl list is JSON-encoded inside content[*].text (exactly like sf_crawl_progress). We decode the first JSON block, then match on url → instanceDirName. The real entry shape (Manager-probed) is {"url":"https://demo-aluminum.example/","instanceDirName":"fc718e3f-..."}.

import csv
import json
from pathlib import Path
from scripts.ingestion import sf_crawl_orchestrator

def _decode_sf_envelope(result: dict):
    """Decode the first JSON payload from an SfMcpClient content envelope.

    SfMcpClient.call_tool returns {"isError":..,"content":[{"text":"<JSON>"}]}.
    Returns the parsed object (list/dict) or None if no JSON block is present.
    """
    for block in (result or {}).get("content", []) or []:
        if isinstance(block, dict) and isinstance(block.get("text"), str):
            try:
                return json.loads(block["text"])
            except ValueError:
                continue
    return None

def _norm_domain(u: str) -> str:
    """Normalise a URL for domain matching: drop scheme + trailing slash."""
    return (u or "").strip().rstrip("/").split("://", 1)[-1].lower()

amber_warnings: list[str] = []
live_findings: list[dict] = []   # parsed SF "Issues Overview" CSV rows
if use_sf_mcp_live:
    from scripts.util.sf_mcp_client import (
        SfMcpClient, SfMcpToolError, SfMcpError,
    )
    sf_cfg = project_config["sf"]["mcp"]
    client = SfMcpClient(base_url=sf_cfg["url"])
    allowed_dir = Path(sf_cfg["allowed_directory"])  # SF write base (D-SF-10)
    if not client.health():
        # R9 AMBER fallback — SF MCP unreachable, continue file-based.
        amber_warnings.append(
            "SF MCP unavailable (health probe failed); "
            "falling back to file-based path"
        )
    else:
        try:
            # 1) Resolve the crawl id: pick the crawl whose `url` matches the
            #    project domain (normalise scheme + trailing slash), then take
            #    its `instanceDirName` (the real saved-crawl id).
            list_resp = client.call_tool("sf_list_crawls")
            decoded = _decode_sf_envelope(list_resp)
            crawls = decoded if isinstance(decoded, list) else (
                decoded.get("crawls", []) if isinstance(decoded, dict) else []
            )
            target = _norm_domain(project_config["domain"])
            match = next(
                (c for c in crawls if _norm_domain(c.get("url", "")) == target),
                None,
            )
            if match is None:
                # R9 AMBER fallback — no crawl for this domain; do NOT hard fail.
                amber_warnings.append(
                    f"No SF crawl found for domain {project_config['domain']!r} "
                    "in sf_list_crawls; falling back to file-based path"
                )
            else:
                crawl_id = match["instanceDirName"]
                # 2) Ensure it is the active loaded crawl (resilient: tolerates
                #    the client-side load timeout, polls progress to confirm).
                client.load_crawl(crawl_id)

                # 3) Export "Issues Overview" via the orchestrator dispatch
                #    (issues_overview_report → ("sf_generate_report",
                #    {"category":"Issues Overview","export_type":"CSV"})). The
                #    >100KB inline cap is resolved by writing to file_path.
                tool, call_kwargs = sf_crawl_orchestrator.SF_EXPORT_DISPATCH[
                    "issues_overview_report"
                ]
                rel_path = "tech_audit_issues_overview.csv"
                client.call_tool(tool, file_path=rel_path, **call_kwargs)

                # 4) Read the written CSV from the SF allowed base dir and parse
                #    it into live_findings rows. issues_overview is a REPORT →
                #    CSV already (no NDJSON conversion). If a future enrichment
                #    wires a seo-element export instead, run
                #    sf_crawl_orchestrator.ndjson_to_csv(...) first (that tool
                #    has no export_type arg and always emits NDJSON).
                csv_path = allowed_dir / rel_path
                # utf-8-sig strips SF's leading BOM so csv.DictReader's first
                # key is clean "Issue Name" (not '"Issue Name"'); the transform
                # also normalizes keys defensively (live-caught 2026-06-02).
                with csv_path.open("r", encoding="utf-8-sig", newline="") as fh:
                    live_findings = list(csv.DictReader(fh))
        except (SfMcpToolError, SfMcpError) as exc:
            # R9 AMBER fallback — list/load/export failed (4xx/5xx, JSON-RPC
            # error, load_crawl settle timeout). NEVER hard fail.
            amber_warnings.append(
                f"SF MCP error: {exc}; falling back to file-based path"
            )

# Pass the parsed rows straight into the transform (default [] / None →
# byte-identical file-based behaviour). The transform merges them
# additively into the per-issue_category tech_seo aggregation.
result = tech_audit_transform.transform(
    lighthouse_raw=raw_lighthouse,
    content_parsing_raw=raw_content_parsing,
    url_cap=url_cap,
    live_findings=(live_findings or None),
)

AMBER vs RED policy (R9 contract — never hard-fail this branch):

  • client.health() returns False → AMBER warning, continue file-based.
  • No crawl matches the project domain in sf_list_crawls → AMBER warning, continue file-based.
  • SfMcpToolError / SfMcpError raised (export 4xx/5xx, JSON-RPC error, load_crawl settle timeout) → AMBER warning, continue file-based.
  • The transform tolerates an empty / absent live_findings gracefully (no extra rows), so any partial SF outage degrades to the file-based result with rowcount(with) >= rowcount(without) preserved.
  • NEVER raise SystemExit from this branch. RED is reserved for the existing DURUR set (budget, schema drift, etc.).

Tool naming reminder (per Manager dispatch): call_tool(tool_name=...) takes the native SF MCP tool name ("sf_generate_report", "sf_list_crawls", "sf_load_crawl"), NOT the registry form ("sf__sf_generate_report") or the Claude Code wrapper form ("mcp__sf__sf_generate_report"). JSON-RPC params.name follows the native form per MCP spec. SfMcpClient already speaks the MCP Streamable-HTTP transport (session handshake + SSE), so the body only ever passes native tool names + the SF call kwargs.

amber_warnings is surfaced via the existing provenance event payload (Step 9 — events_writer.append_provenance accepts a notes field when present) and the rendered report (Step 9 — render_template template variable $amber_warnings).

Issue detection rules — severityEnum coverage

Every emitted row's impact is one of {CRITICAL, HIGH, MEDIUM, LOW} (strict — see tech_audit_transform.SEVERITY_ENUM and schemas/master-excel.schema.json $defs/severityEnum).

CRITICAL is reserved for findings the rule set does not currently emit (e.g. site-wide outage, robots.txt blocks all). HIGH/MEDIUM/LOW are the active emission tiers — they map to P0/P1/P2 priority via _PRIORITY_BY_SEVERITY.

DURUR conditions (10)

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

  1. Budget pre-flight FAIL (check_budget.py exit 1 OR projected_used > budget) → BudgetExceededError → workflow routed to awaiting_approval per spec §10. NEVER silently downgrade.
  2. mcp__dataforseo__on_page_lighthouse or mcp__dataforseo__on_page_content_parsing returns auth/network/scope error → STOP.
  3. DoS prevention: URL list count > url_cap (default 50) → URLCapExceededError. Surface to manager; manager raises cap or splits the run.
  4. Lighthouse response schema drift (e.g. categories.performance.score field renamed, audits block missing) → LighthouseSchemaDriftError.
  5. content_parsing response schema drift (meta block missing AND page_content missing AND no flat title) → ContentParsingSchemaDriftError.
  6. inbox/dfs/ path cannot be created (workspace path missing / read-only).
  7. tech_seo column count or names mismatch the schema-locked TECH_SEO_COLUMNS tuple → RowSchemaError (defensive — should never fire for module-built rows; fires if a future patch adds a column without updating the schema).
  8. transaction.append raises RowSchemaError (severityEnum violation, row column drift).
  9. workflow_runner.create_run fails schema validation (workflow-run.schema.json).
  10. PSEO_WORKSPACE_ROOT env unset and no workspace_root arg passed.

Awaiting-approval state path (Budget DURUR routing, ADR-016)

When DURUR #1 fires the skill MUST:

  1. Catch BudgetExceededError raised by preflight_budget().
  2. workflow_runner.request_approval(...) with the budget envelope serialized into the subject (so manager sees projected vs cap).
  3. EXIT — do NOT continue to step 3. The workflow state is now awaiting_approval and the manager session decides between (a) raise dataforseo.budget_credits_per_day in project-config, (b) split the URL list and re-run, (c) defer the audit.

This contract is identical to dfs-pull and is the convention for all paid-MCP discovery skills (Phase 7+).

Cross-references

  • Schemas: schemas/master-excel.schema.json (tech_seo sheet, 6 cols; severityEnum $defs), schemas/events.schema.json (source.kind=dataforseo_mcp, target_excel_sheet=tech_seo, cost.credits per ADR-016), schemas/skill-frontmatter.schema.json (this frontmatter), schemas/dataforseo-endpoint-mapping.schema.json (DFS contract; cost.credits_per_call).
  • Cross-modules (IMPORT-only, not modified): scripts/state/workflow_runner.py, scripts/excel/transaction.py, scripts/state/events_writer.py, scripts/budget/check_budget.py, scripts/reporting/render_template.py.
  • Transform: scripts/discovery/tech_audit_transform.py (_normalize_dfs_response, estimate_credits, preflight_budget, transform, severity/category constants, exception hierarchy).
  • Tests: tests/skills/test_tech_audit.py (≥10 cases incl. budget pre-flight mock + DoS cap + lighthouse/content-parsing schema drift + severityEnum coverage).
  • Template: templates/reports/tech-audit.template.md.

Discipline checklist

  • TODO/fallback YASAK — every DURUR raises, none silently downgrade.
  • Schema-first — frontmatter validates against schemas/skill-frontmatter.schema.json Draft 7; tech_seo rows validate against master-excel.schema.json 6 cols.
  • Plugin-agnostik — no slug literals; project_slug flows through every path; verified by the forbidden-slug regex grep documented in the worker brief (0-tolerance gate).
  • Budget pre-flight integration — scripts.budget.check_budget invoked via subprocess at step 1 BEFORE any paid call (ADR-016 SSoT for cost arithmetic).
  • Append-only — all events via events_writer; per-DFS-call provenance event carries cost.credits (ADR-016).
  • Cross-module IMPORT discipline — workflow_runner / events_writer / check_budget are imported (subprocess for check_budget; module reuse pattern from dfs-pull). scripts.excel.transaction is called only from the SKILL orchestrator at step 8 — the transform module never imports it.
  • severityEnum strict — every emitted row's impact is in {CRITICAL, HIGH, MEDIUM, LOW}; defensive _validate_row runs twice (transform + transaction.append).
  • DoS prevention — URL count capped at url_cap (default 50); URLCapExceededError is DURUR #3.
  • 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.