Skip to content
Back to skills

Internal Links

ASecurity

Use when: kullanıcı "internal links", "iç linkleme", "orphan page", "kayıp sayfa", "iç link kırığı", "broken internal link", "redirect chain", "yönlendirme zinciri", "anchor distribution", "anchor text dağılımı", "iç link analizi" der ya da /pseo-internal-links çağırır. Also use when: aktif projenin SF export'u mevcut (sf-import koşmuş); projects/{slug}/sf-exports/{date}/raw/ altında all_inlinks.csv + internal_all.csv var; orphan / broken / redirect-chain / anchor diversity bulguları master.x...

  • 27 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added October 2, 2026
ai-agentspythongoshellbashapi

Works with

  • claude code
  • cli
  • api
  • mcp

Security analysis

A100/100

Scanned October 2, 2026

npx -y skills add popiliadam/platinum-seo-engine --skill internal-links --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Internal Links?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Internal Links
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/popiliadam-internal-links/badge)](https://www.skillsdirectory.com/skills/popiliadam-internal-links)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: internal-links
description: |
  Use when: kullanıcı "internal links", "iç linkleme", "orphan page",
  "kayıp sayfa", "iç link kırığı", "broken internal link", "redirect
  chain", "yönlendirme zinciri", "anchor distribution", "anchor text
  dağılımı", "iç link analizi" der ya da /pseo-internal-links çağırır.
  Also use when: aktif projenin SF export'u mevcut (sf-import koşmuş);
  projects/{slug}/sf-exports/{date}/raw/ altında all_inlinks.csv +
  internal_all.csv var; orphan / broken / redirect-chain / anchor
  diversity bulguları master.xlsx#master_task'a auto_generated entry
  olarak yazılacak; quick-wins / cannibalization sonrası ek planning
  pass'i olarak çağırılır.
  Do not use when: SF import yapılmamış (önce sf-import çalıştır,
  DURUR #1); GSC quick-wins / cannibalization tetiklenecek (ayrı
  discovery skill'leri); tek-URL on-page audit (on-page-audit); DFS
  bütçesi aşılmışsa --use-dfs flag KULLANMA, default no-cost path
  yeterlidir. Master.xlsx yokken çağırma; init-project önce çalışmalı
  (DURUR #6).
version: "1.0"
status: active
category: planning
inputs:
  project_slug:
    type: string
    required: true
    description: "Slug; resolves projects/{slug}/master.xlsx + sf-exports/."
  sf_export_date:
    type: string
    required: false
    description: "ISO date of SF export to read (projects/{slug}/sf-exports/{date}/raw/). Default: latest folder under sf-exports/."
  default_status:
    type: string
    required: false
    default: "TODO"
    description: "statusEnum seed for new master_task rows (default TODO)."
  max_entries:
    type: integer
    required: false
    default: 500
    description: "Hard cap on auto_generated rows; DURUR #7 vehicle when exceeded."
  use_dfs:
    type: boolean
    required: false
    default: false
    description: "Optional: enrich findings via mcp__dataforseo__on_page_content_parsing. Triggers budget pre-flight (paid MCP)."
  use_sf_mcp_live:
    type: boolean
    required: false
    default: false
    description: "Opt-in (D-SF-11): when true, calls SF MCP via scripts/util/sf_mcp_client.SfMcpClient for live all_inlinks (bypasses sf-exports CSV freshness gap). Resolves the crawl id from sf_list_crawls (domain match → instanceDirName), client.load_crawl(...) (resilient), then exports all_inlinks via SF_EXPORT_DISPATCH (sf_generate_bulk_export category 'Links:All Inlinks', export_type CSV) to file_path (the >100KB inline cap is resolved by writing to disk, not a non-existent 'truncated' flag), reads the native CSV with utf-8-sig (strips SF's BOM), parses with csv.DictReader → transform(inlinks_rows=...). Requires SF GUI + MCP server running (preflight via client.health(); on FAIL / no matching crawl / SfMcpToolError / load timeout → AMBER fallback to file-based path, NEVER hard fail per R9)."
outputs:
  - "master.xlsx#master_task"
  - "outputs/reports/{date}-internal-links.md"
  - "events.jsonl"
  - "inbox/sf/{date}-internal_links-{slug}.json"
consumes:
  - "init-project:projects/{slug}/master.xlsx"
  - "sf-import:projects/{slug}/sf-exports/{date}/raw/"
produces:
  - "drift-check"
  - "monthly-report"
triggers:
  manual: ["/pseo-internal-links"]
  natural_language: |
    "internal links", "iç linkleme", "orphan page", "kayıp sayfa",
    "iç link kırığı", "broken internal link", "redirect chain",
    "yönlendirme zinciri", "anchor distribution", "anchor text dağılımı",
    "iç link analizi"
  hooks: []
  scheduled:
    - cron: "0 9 * * 3"
      mode: "report-only"
mcp_tools:
  required: []
  optional:
    - "mcp__dataforseo__on_page_content_parsing"
    - "mcp__sf__sf_list_crawls"
    - "mcp__sf__sf_load_crawl"
    - "mcp__sf__sf_generate_bulk_export"
budget:
  uses_paid_mcp: false
  estimated_credits: 0
autonomy:
  confidence: HIGH
  requires_approval: true
  safe_auto_execute: false
---

# internal-links — planning skill (Phase 8 Wave 1)

10-step protocol. Steps map 1:1 to `workflow_runner` invocations + the
spec §6 SF ingest discipline + §16.5 raw-inbox transform discipline.
Raw CSV envelope discipline is mandatory: SF CSVs are described in a
single envelope-JSON dropped into `inbox/sf/` *before* any per-row
transform runs, so a transform bug never costs us the upstream payload.

This skill follows the **convention authority** established by
`skills/discovery/cannibalization/SKILL.md` (Phase 7 Wave 1) and
`skills/ingestion/sf-import/SKILL.md` (Phase 5 Wave 2). The 10-step
protocol shape, raw envelope discipline, URL normalization (D-03),
DURUR + flag rule, and provenance event format are reused verbatim —
only the data source (SF CSV instead of GSC MCP) and the target sheet
(master_task auto_generated rows instead of a sheet-specific layout)
change. Deviate only with an ADR.

Unlike Phase 7 discovery skills that read GSC/DFS MCP responses, this
planning skill reads **SF CSV files directly** from disk. There is NO
DFS dependency in the default path; `_normalize_dfs_response` (D-003
helper) is NOT imported here because no DFS payload is consumed. The
optional `--use-dfs` enrichment path (anchor-text quality scoring) DOES
gate behind `scripts/budget/check_budget.py` per §16.8 + ADR-016, but
the default flow burns 0 credits.

## Inputs (frontmatter contract)

| Name              | Type    | Default | Notes                                                     |
|-------------------|---------|---------|-----------------------------------------------------------|
| `project_slug`    | string  | —       | Required. Resolves `projects/{slug}/master.xlsx` + sf-exports/. |
| `sf_export_date`  | string  | latest  | Reads `projects/{slug}/sf-exports/{date}/raw/`.           |
| `default_status`  | string  | "TODO"  | statusEnum seed for new master_task rows.                 |
| `max_entries`     | integer | 500     | DURUR #7 cap; >500 entries → flag for batching.           |
| `use_dfs`         | boolean | false   | Opt-in DFS enrichment; triggers budget pre-flight.        |

`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#master_task` — N auto_generated rows
  (one per orphan / broken / redirect-chain / anchor finding), 19
  columns, `auto_generated=true`, `primary_source=tech_fix`.
- `projects/{slug}/outputs/reports/{date}-internal-links.md` —
  human-readable summary (orphan list + broken counts + redirect
  chains + anchor diversity flags).
- `projects/{slug}/_state/events.jsonl` — `event_kind=provenance`
  entry (`source.kind=sf_csv`, `target_excel_sheet=master_task`).
- `projects/{slug}/inbox/sf/{date}-internal_links-{slug}.json` —
  drift-recovery envelope listing the SF CSVs read + finding counts.

## Output Target Decision (Option A vs C)

The brief gave three options for where internal-links findings land:

| Option | Target                                      | Schema Bump | Decision |
|--------|---------------------------------------------|-------------|----------|
| A      | `master.xlsx#master_task` auto_generated    | NONE        | **CHOSEN** |
| B      | New `master.xlsx#internal_links` sheet      | REQUIRED → ADR | OUT OF SCOPE (Wave 1, manager-only) |
| C      | staging-only (`_state/staging/...json`)     | NONE        | rejected |

**Choice: Option A — D-01 SSoT extension via master_task auto_generated entries.**

**Rationale:**
1. **Single source of truth (D-01).** `master_task` is already the
   workspace-wide task table consumed by `dashboard_refresh`,
   `done_protocol`, `whats-next`. Putting internal-links findings
   there means no downstream skill needs to learn a new sheet name —
   reuse the existing `master_task_sync` writer scope (allowed_writers
   list in master-excel.schema includes `master_task_sync`, which is
   the planning-skill writer convention).
2. **Schema-neutral.** Option B's new sheet would force a schema
   bump → ADR → manager approval, breaking Wave 1 worker scope.
   Option A uses only existing columns + existing enum values.
3. **`auto_generated=true` flag is purpose-built** (col O,
   `type: boolean`) — it tells dashboard_refresh / done_protocol that
   the row was synthesized by a planning skill and is eligible for
   bulk dismissal / batch-approve flows. The schema enforces:
   - `auto_generated` (col O) MUST be a boolean.
   - `primary_source` (col C) MUST be in the closed enum
     {content_decay, quickwin, tech_fix, schema, pillar, manual,
     sxo, cannibalization, redirect_404}. We use **`tech_fix`**
     (closest match for structural/internal-links findings; the
     enum has NO `internal_links` value, see Open Question Q-IL-1).
   - `note` carries the analysis lineage prefix `[internal-links/{kind}]`
     so glossary-audit / drift-check can find these rows by prefix.
   - `metric_impact_json` (col R) carries the per-finding diagnostic
     payload (kind, status_code, inbound_count, etc.).
4. **Volume-tolerant via DURUR #7.** If a single run produces
   >500 auto_generated entries, the transform raises
   `MaxEntriesExceededError` and the skill flags the manager to
   batch the run. Option C would silently dump everything and
   defer the volume problem to Phase 9 — not better.

Option C (staging-only) was considered as fallback when entry count
exceeds the cap; rejected because dashboard_refresh / whats-next
already speak master_task — adding a parallel staging path doubles
the surface area for Phase 9 reporting integration without any data
benefit. If volume becomes structural (every run > cap), revisit via
ADR (Option B schema bump).

**Source-field convention** for auto_generated entries:

| Column           | Value (per row)                                                  |
|------------------|------------------------------------------------------------------|
| `task_id`        | `{slug}-{date}-il-{kind}-{idx:03d}` (deterministic, slug-agnostic) |
| `task`           | Human label (e.g. "Orphan page (no internal inlinks): {url}")    |
| `primary_source` | `"tech_fix"` (enum value)                                        |
| `related_sources`| `"internal-links"` (free-text reference)                         |
| `category`       | `"internal-links"` (free-text)                                   |
| `note`           | `"[internal-links/{kind}] {detail}"` (machine-parseable prefix)  |
| `auto_generated` | `True` (boolean — col O type=boolean)                            |
| `metric_impact_json` | sorted-keys JSON of finding metrics                          |

## 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 — `create_run`

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

```python
from scripts.state import workflow_runner
handle = workflow_runner.create_run(
    skill="internal-links",
    project_slug=project_slug,
    steps=[
        {"name": "load_sf_csvs"},
        {"name": "envelope_inbox"},
        {"name": "transform"},
        {"name": "request_approval"},
        {"name": "write_excel"},
        {"name": "render_report"},
    ],
)
```

### Step 2 — `load_sf_csvs` (SF CSV first — drift-recoverable)

```python
from scripts.planning import internal_links_transform as ilt
raw_dir = (
    workspace_root / "projects" / project_slug
    / "sf-exports" / sf_export_date / "raw"
)
internal_rows, inlinks_rows, outlinks_rows = ilt.load_sf_csvs(raw_dir)
```

Raises `SFDataMissingError` (DURUR #1) when the dir is missing or no
SF CSVs are found, and `CSVParseError` (DURUR #2) on header mismatch
or encoding failure.

### Step 3 — `envelope_inbox` (raw → drift-recoverable JSON)

For each loaded CSV: capture filename, row count, header set, and a
sha256 of the file bytes. Drop the envelope into
`inbox/sf/{date}-internal_links-{slug}.json` BEFORE any transform
runs. The envelope is the durable witness that a given SF batch was
observed even if the Excel write fails downstream.

```python
envelope = {
    "_meta": {
        "captured_at": _utc_iso_z(),
        "tool": "screaming_frog",
        "skill": "internal-links",
        "project_slug": project_slug,
        "sf_export_path": str(raw_dir),
    },
    "files": [
        {"canonical_name": "internal_all", "row_count": len(internal_rows or [])},
        {"canonical_name": "all_inlinks", "row_count": len(inlinks_rows or [])},
        {"canonical_name": "all_outlinks", "row_count": len(outlinks_rows or [])},
    ],
}
inbox_path = (
    workspace_root / "projects" / project_slug
    / "inbox" / "sf"
    / f"{today.isoformat()}-internal_links-{project_slug}.json"
)
inbox_path.parent.mkdir(parents=True, exist_ok=True)
inbox_path.write_text(
    json.dumps(envelope, ensure_ascii=False, indent=2),
    encoding="utf-8",
)
```

### Step 4 — `transform` (pure compute)

```bash
python3 scripts/planning/internal_links_transform.py \
    --raw-dir projects/{slug}/sf-exports/{date}/raw/ \
    --project-slug {slug} \
    --run-date {date} \
    --default-status TODO \
    --max-entries 500 \
    --output-dir _state/transform/{run_id}/
```

Produces a JSON array (`master_task`) shaped to the master-excel
schema (19 columns: task_id, task, primary_source, related_sources,
url, category, priority, impact, duration_est_min, status,
created_date, done_date, assignee, note, auto_generated, dependencies,
effort_actual_min, metric_impact_json, work_log_ref). URL normalization
(D-03) is applied here, not at load time, so the inbox envelope is
byte-faithful to the SF export.

The transform is idempotent: same inputs → byte-identical output.

If the result has `meta.row_count == 0`, the skill SKIPS write_excel
and emits a "no internal-links findings" notice (DURUR #8 — clean
exit, not error).

### Step 5 — `request_approval` (skill EXIT awaiting_approval)

```python
workflow_runner.request_approval(
    handle.run_id, project_slug=project_slug,
    approver="user",
    subject=f"{len(rows)} internal-links bulgu (orphan/broken/redirect/anchor); "
            "master.xlsx#master_task'a auto_generated entry yazalım mı?",
    step_index=3,
)
# Skill exits here. The user replies in a fresh session; resume below.
```

### Step 6 — Resume (`approve` → continue)

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

### Step 7 — `write_excel` (atomic, schema-validated)

Single `transaction.append` call to the master_task sheet. The writer
is `master_task_sync` (allowed_writers list in master-excel.schema).
Goes through the single approved write path with backup, lock, schema
validation, and post-write provenance event emission.

```python
from scripts.excel import transaction
transaction.append(
    workbook_path=workspace_root/"projects"/project_slug/"master.xlsx",
    sheet="master_task",
    rows=master_task_rows,
    project_slug=project_slug,
    writer="master_task_sync",
)
```

### Step 8 — `render_report`

`render_template.py templates/reports/internal-links.template.md
data.json` → `outputs/reports/{date}-internal-links.md`. Variables:
`$project_slug`, `$date`, `$orphan_count`, `$broken_count`,
`$redirect_chain_count`, `$anchor_diversity_count`, `$top_orphan_url`,
`$top_broken_url`, `$report_summary`.

### Step 9 — Provenance event

```python
from scripts.state import events_writer
events_writer.append_provenance(
    project_id=project_slug,
    source={
        "kind": "sf_csv",
        "source_folder": str(raw_dir.relative_to(workspace_root)),
        "row_count": (
            len(internal_rows or []) + len(inlinks_rows or [])
        ),
    },
    operation="project_excel",
    target_excel_sheet="master_task",
    rows_written=len(master_task_rows),
)
```

### Step 10 — `complete`

```python
workflow_runner.complete(handle.run_id, project_slug=project_slug, outputs={
    # F5: outputs.* must be STRING-TYPED
    "row_count": str(len(master_task_rows)),
    "orphan_count": str(meta["orphan_count"]),
    "broken_count": str(meta["broken_count"]),
    "redirect_chain_count": str(meta["redirect_chain_count"]),
    "anchor_diversity_count": str(meta["anchor_diversity_count"]),
    "report_path": str(report_path),
    "envelope": str(inbox_path),
})
```

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

**Default: `use_sf_mcp_live=false`** — file-based ingestion (SF CSV from
`projects/{slug}/sf-exports/{date}/raw/`) is the canonical contract.
The flag is opt-in only and gated by a `client.health()` preflight; on
probe failure the run AMBER-warns and continues with the file-based
path (never hard-fail per R9 mitigation).

When `use_sf_mcp_live=true`, the SKILL body branches at Step 2
(`load_sf_csvs`) to optionally pull `all_inlinks` rows inline from SF
MCP instead of reading the on-disk CSV. The default no-DFS, no-MCP
path remains 0 credits and continues to satisfy the existing SF CSV
fixtures used in the 1184+ baseline.

**This branch was rewritten (AC-13 replication) against the REAL SF MCP
API** — the same class of fix as tech-audit (landed in `6ee6fb9`,
live-proven on the demo-aluminum crawl) and the sf-crawl-orchestrator
Step-5 export dispatch (`a714e43`). The engine canonical `all_inlinks`
is NOT a Screaming Frog identifier: it maps via
`sf_crawl_orchestrator.SF_EXPORT_DISPATCH` to the real
`sf_generate_bulk_export` tool with `category="Links:All Inlinks"` +
`export_type="CSV"`. There is NO `crawl_id`, `report_name`, or
`save_report` arg; the export 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 — and
all_inlinks is the single largest export (one row per edge), so
`file_path` (write-to-disk) is the only safe path (OQ-FILEPATH-EXPORTS),
not a non-existent `truncated` flag.

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-..."}`.

`sf_generate_bulk_export` writes a **native CSV WITH a leading UTF-8 BOM**
(live-verified) — so the written file is read with `encoding="utf-8-sig"`,
otherwise `csv.DictReader`'s first key arrives BOM-quoted
(`'"Address"'`) and every row silently drops.

```python
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 + leading www. +
    trailing slash."""
    bare = (u or "").strip().rstrip("/").split("://", 1)[-1].lower()
    return bare[4:] if bare.startswith("www.") else bare

amber_warnings: list[str] = []
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 + leading www. + trailing
            #    slash), then take its `instanceDirName` (the real 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 "Links:All Inlinks" via the orchestrator dispatch
                #    (all_inlinks → ("sf_generate_bulk_export",
                #    {"category":"Links:All Inlinks","export_type":"CSV"})). The
                #    >100KB inline cap is resolved by writing to file_path.
                tool, call_kwargs = sf_crawl_orchestrator.SF_EXPORT_DISPATCH[
                    "all_inlinks"
                ]
                rel_path = "all_inlinks.csv"
                client.call_tool(tool, file_path=rel_path, **call_kwargs)

                # 4) Read the written CSV from the SF allowed base dir. A BULK
                #    export is NATIVE CSV → read directly (NO ndjson_to_csv).
                #    utf-8-sig strips SF's leading BOM so csv.DictReader's first
                #    key is clean "Address" (not '"Address"'); without it
                #    every row would silently drop (the AC-13 BOM bug class).
                csv_path = allowed_dir / rel_path
                with csv_path.open("r", encoding="utf-8-sig", newline="") as fh:
                    inlinks_rows = list(csv.DictReader(fh))
                # The transform's per-destination aggregator (broken /
                # redirect-chain / anchor-diversity) consumes the same
                # DictReader-shaped rows whether they come from CSV or MCP.
        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 file-based
# inlinks_rows when the MCP path was skipped / fell back).
result = internal_links_transform.transform(
    internal_all_rows=internal_all_rows,
    inlinks_rows=inlinks_rows,
    project_slug=project_slug,
    run_date=run_date,
)
```

**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.
- A partial SF outage degrades to the file-based `all_inlinks.csv`
  (orphan/broken/redirect-chain detectors handle missing edges
  gracefully — affected_urls just understates).
- NEVER raise `SystemExit` from this branch. RED reserved for the
  existing DURUR set (SF data missing in BOTH paths, CSV parse error,
  max_entries exceeded, etc.).

**Tool naming reminder:** `call_tool(tool_name=...)` takes the **native**
SF MCP tool name (`"sf_generate_bulk_export"`, `"sf_list_crawls"`,
`"sf_load_crawl"`), NOT the registry form (`"sf__sf_generate_bulk_export"`)
or the Claude Code wrapper form (`"mcp__sf__sf_generate_bulk_export"`).
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
(an additional `source.kind=sf_mcp` event is emitted alongside the
sf_csv event at Step 9) and the rendered report template variable
`$amber_warnings`.

## URL normalization (D-03 invariant)

Every URL passing through this skill is normalized via
`scripts.planning.internal_links_transform.normalize_url`. The function
is **idempotent**: `normalize_url(normalize_url(u)) == normalize_url(u)`.
Rules: lowercase scheme+host, IDN→punycode, strip default ports, strip
trailing slash (root excluded), drop fragment, drop tracking params,
sort remaining query keys.

The transform self-checks idempotency on every emitted URL (post-row
build) — drift between transform and downstream consumers is escalated
as `RowSchemaError` rather than silently masked.

## Detection rules (4 finding kinds)

| Kind             | Condition                                                       | Severity | Duration |
|------------------|-----------------------------------------------------------------|----------|----------|
| orphan-page      | status=200 + indexable + 0 inlinks                              | HIGH     | 30 min   |
| broken-link      | inlink edge destination status_code 4xx/5xx (per-dest agg)      | HIGH/CRITICAL/MEDIUM | 15 min |
| redirect-chain   | **redirected internal link (single hop)** — inlink edge destination status_code 3xx (per-dest agg) | LOW/MEDIUM | 20 min |
| anchor-diversity | dest URL ≥5 inbound non-empty anchors + only 1 distinct anchor | LOW      | 25 min   |

> **`redirect-chain` is a misnomer — it detects a single hop (I7).** The
> `redirect-chain` kind fires when an internal link points to a URL that
> returns a 3xx, i.e. a **redirected internal link (single hop)** — it does
> NOT trace a multi-hop chain. Real multi-hop redirect chains come from
> Screaming Frog's `redirect_chains` report (the `Number of Redirects` /
> `Redirect Chain` columns), which is consumed by GAP-T4 later. The machine
> `kind` id stays `redirect-chain` (stable `task_id` contract); only the
> human-facing description is corrected here.

`task_id` format: `{slug}-{run_date}-il-{kind}-{idx:03d}` —
deterministic + slug-agnostic; same input produces the same task_id
across re-runs.

## Optional DFS path (paid MCP enrichment)

When the manager passes `use_dfs=true`, the skill enriches the
findings with `mcp__dataforseo__on_page_content_parsing` (anchor-text
quality scoring, link-position weighting). Pre-flight is MANDATORY:

```python
from scripts.planning import internal_links_transform as ilt
estimate = ilt.estimate_credits(url_count=len(internal_rows), use_dfs=True)
ilt.preflight_budget(
    estimated_credits=estimate,
    project_config_path=workspace_root/"projects"/project_slug/"project.config.json",
    events_path=workspace_root/"projects"/project_slug/"_state"/"events.jsonl",
)
```

`BudgetExceededError` (DURUR #6) is raised when projected daily usage
exceeds the cap; the skill must route the workflow to
`awaiting_approval` per ADR-016 / spec §10. The default no-DFS path
burns 0 credits and skips this gate entirely.

## DURUR conditions (8)

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

1. SF data missing — `projects/{slug}/sf-exports/{date}/raw/` does
   not exist OR is empty (no `internal_all.csv` / `all_inlinks.csv` /
   `all_outlinks.csv`). STOP, point user to run `sf-import` first.
   `SFDataMissingError`.
2. `all_inlinks.csv` parse fail (header mismatch / encoding bad).
   STOP, do not silently emit empty rows. `CSVParseError`.
3. `master.xlsx#master_task` column count or names don't match
   schema (`schemas/master-excel.schema.json#master_task`,
   19 required_columns). STOP, schema-first violation. Caught at
   transform boundary as `RowSchemaError`.
4. `transaction.append` raises `RowSchemaError` (e.g.
   `auto_generated` not boolean, `status` not in statusEnum,
   `priority` not in severityEnum). STOP.
5. Output target ambivalence — manager passed Option B request
   (new sheet + schema bump). NOT in Wave 1 scope; route to manager
   for ADR draft.
6. Optional DFS path budget pre-flight FAIL (only when
   `use_dfs=true`). `BudgetExceededError` — route to
   `awaiting_approval` per ADR-016 / spec §10.
7. `MaxEntriesExceededError` — emitted entries exceed `max_entries`
   (default 500). Manager flag for batching review (split run by
   sub-folder or by finding kind).
8. Transform reports `meta.row_count == 0` (no internal-links
   findings). NOT an error — clean exit with a "no internal-links
   findings" notice; skill skips write_excel + render_report and goes
   straight to `complete` with `row_count="0"`.

## Cross-references

- Schemas: `schemas/master-excel.schema.json#master_task`
  (19 required_columns + `#/definitions/statusEnum` +
  `#/definitions/severityEnum` + `primary_source` enum),
  `schemas/events.schema.json` (`source.kind=sf_csv`,
  `target_excel_sheet=master_task`),
  `schemas/sf-required-reports.schema.json` (Tier 1 placement of
  `internal_all`, `all_inlinks`, `all_outlinks`),
  `schemas/skill-frontmatter.schema.json` (this frontmatter).
- Cross-modules (IMPORT-only): `scripts/state/workflow_runner.py`,
  `scripts/excel/transaction.py`, `scripts/state/events_writer.py`,
  `scripts/reporting/render_template.py`,
  `scripts/budget/check_budget.py` (only when `use_dfs=true`).
- Transform: `scripts/planning/internal_links_transform.py`.
- Tests: `tests/skills/test_internal_links.py` (≥6 cases).
- Pilot data path: `projects/{slug}/sf-exports/{export_date}/raw/`.

## Discipline checklist

- [x] TODO/fallback YASAK — every DURUR raises (exception: DURUR #8 is
      a clean no-op exit, not a fallback).
- [x] Schema-first — frontmatter validates against
      `schemas/skill-frontmatter.schema.json` Draft 7; master_task
      column tuple verified against `schemas/master-excel.schema.json`.
- [x] Plugin-agnostik — no slug literals; `project_slug` flows through.
- [x] ADR-013: Use when / Also use when / Do not use when are STRING
      content inside `description`, not separate fields.
- [x] Cross-module IMPORT discipline — `transaction` / `workflow_runner`
      / `events_writer` imported, never modified.
- [x] F1: write target is `master.xlsx` (lowercase, schema-shaped).
- [x] F5: `outputs.*` values are STRING-TYPED, never raw ints.
- [x] Append-only state — `events.jsonl` only grows.
- [x] D-003 helper NOT applicable — no DFS payload in default path;
      `_normalize_dfs_response` is NOT imported. The transform stays pure.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.