---
name: property-extract
description: Use when a live property listing URL on any UK estate-agent website needs to be turned into a local property assets directory containing photos, floorplans, EPC documents, and structured metadata that the property-brochure skill can consume. Trigger phrases include "extract property from <url>", "get property details from <url>", "scrape this listing", "pull all the photos from <listing>", "stage assets for the brochure", or any request that names a live property URL and asks for the photos, floorplans, and details to be saved locally.
allowed-tools:
  - Bash
  - Read
  - Write
  - Edit
  - Glob
  - Grep
  - WebFetch
---

# property-extract

Turn one publicly-accessible property listing URL into a self-contained property assets directory that `property-brochure` can consume without re-visiting the source site.

The contract is **portal-agnostic**: the output shape is the same whether the source is an agent's own site (Muvin / Loop CRM tenants, custom builds) or a portal (Rightmove, Zoopla, OnTheMarket, OpenRent). The static-HTML probe is the cheap default; when it returns no asset URLs or the data is tab-bound, fall back to a headless browser. See **Process — high level**.

## Outcome contract

The skill is **complete** when the target directory contains exactly this structure:

```
<output-dir>/<property-slug>-<listing-id>/
  property.json          # structured metadata
  description.md         # human-readable description + agent + EPC + council tax
  epc/                   # EPC certificate PDF/image, OR empty if rating is "TBC"
  floorplans/            # all floorplan images, original filenames
  images/                # all property photographs and renders, original filenames
```

Every photograph and floorplan referenced by the listing must be downloaded. `epc/` may legitimately be empty (if no EPC has been issued); `images/` and `floorplans/` must not be empty if the listing exposes any.

Do **not** pre-create a `brochure/` slot. `property-brochure` writes its deliverable directly to `<property_dir>/output/` — see `property-brochure/SKILL.md → Reference standard`. An empty `brochure/` next to `output/` is the legacy layout and a defect.

`<property-slug>` comes from the URL path segment (e.g. `<street-locality-postcode>` for Loop-CRM-style URLs, or whatever slug component the source portal exposes). `<listing-id>` is the numeric ID, also from the URL. Concatenating them keeps the folder both human-readable and unique even when the same street has multiple listings.

Anything else (raw HTML dumps, `/tmp` files, intermediate URL lists) must be cleaned up before reporting completion.

## Inputs

Required: a property listing URL, fully-formed and publicly accessible. If only a postcode or property name is given, decline and ask for the URL — listings are uniquely identified by their numeric IDs, and there is no reliable way to infer one from a description.

Optional: an output directory. The default is `<brand_dir>/properties/`, where `<brand_dir>` is the brand workspace for the agent that owns the listing. If a brand pack already exists at `<brand_dir>/DESIGN.md`, properties nest under `<brand_dir>/properties/`. If the brand workspace cannot be located, fall back to `./properties/` in the caller's current working directory.

The property directory **must** sit inside its brand workspace at `<brand_dir>/properties/<property_slug>-<listing_id>/` — never as a sibling of the brand workspace, never inside another property's folder. This is the contract `make-brochure` and `property-brochure` read against.

## Scope boundaries

| In scope | Out of scope |
|---|---|
| Download every CDN-hosted image, floorplan and EPC asset referenced by the listing page | Stripping AI-generated images from the photo set — keep them all and flag in metadata |
| Extract description copy verbatim from whichever description container the page exposes | Paraphrasing or rewriting the description — the brochure step owns voice |
| Capture a screenshot of any map element by heuristic (provider-agnostic — Google Maps iframe, Mapbox canvas, static-map `<img>`, custom embed) | Synthesising a map when the listing has none (no postcode-centroid renders, no AI maps) |
| Property description, key features, price, specs, agent contact | Walkthrough video transcription (the property-brochure skill handles that separately if the user supplies one) |
| EPC rating as a value, EPC certificate PDF if linked from the page | EPC certificate retrieval from the EPC Register — that's a separate lookup; if the listing does not link the certificate, leave `epc/` empty and record `"epc_rating": "TBC"` (or the band shown) in `property.json` |
| Council tax band as shown on the page | Council tax band verification against VOA — out of scope |
| Material Information visible on the listing page (tenure, council tax, EPC) | Full Part A/B/C compliance gathering — that's the brochure assembly step's job |

The skill is portal-agnostic. The Loop-CRM CDN pattern (`*.b-cdn.net/propertyimages/...`, `*.b-cdn.net/floorplans/...`) is one common example that the static-HTML probe handles cheaply; agency-specific platforms (Rightmove, Zoopla, OnTheMarket, OpenRent, custom builds) JS-render most data and require the Playwright fallback documented in **Process — high level** step 4b. When the static probe returns no asset URLs or critical fields are tab-bound, escalate to the headless-browser fallback as a first-class path, not a conditional one. If even that returns nothing, stop and report `STATUS: BLOCKED` — do not synthesise an asset list.

## Artifact contracts

### Folder layout

The three subfolders (`images/`, `floorplans/`, `epc/`) are **mandatory and named exactly** as specified — they are part of the contract that `property-brochure` reads against. Do not pluralise differently, do not collapse `epc/` into `floorplans/`, do not add a sibling `videos/` or `brochure/` folder. If a future asset class needs accommodating, propose a contract change to the plugin first.

### `images/`

Every URL on the page pointing at an image asset is in scope. On Loop-CRM tenants the pattern is `b-cdn.net/propertyimages/<tenant-uuid>/<listing-id>/` — two example buckets in active use as of April 2026 are `loop-app.b-cdn.net` (older 2024-vintage uploads) and `loopcrm.b-cdn.net` (newer 2026 additions including drone shots, AI-staged renders, screenshots). Other portals serve from their own hosts (e.g. Rightmove's `media.rightmove.co.uk`); the rule is the same — grep every image URL the page emits, deduplicate, download all of them.

Preserve the original filenames — many tenants encode upload timestamps as Unix epochs in the filename (e.g. `DSC01495_1727361503898.jpg` → 26 Sept 2024) which are useful for ordering and de-duplication later. Where filenames carry no timestamp, fall back to download order.

Image categories observable from filename prefixes:

| Prefix | Origin |
|---|---|
| `DSC*`, `IMG*` | Photographer's DSLR/mirrorless |
| `DJI_*` | Drone imagery |
| `ChatGPT-Image-*` | AI-staged render |
| `Screenshot-*` | Map/aerial screenshot from a desktop tool |
| `iPhone*`, `WhatsApp*` | Quick mobile snaps (lower resolution) |

Record the count per prefix in `property.json` under `media.image_breakdown` — `property-brochure` uses this to pick the right photo for each slot.

#### Curator-friendly rename — "<Property Name> N.jpg"

After downloading, **also rename the images to `<Property Name> N.jpg`** (1-indexed, ordered by upload timestamp parsed from the original filename) so a human curator can browse `images/` in Finder/Preview and intuitively pick which photo goes in which slot. The downstream `property-brochure` slot map references `images/<property-slug>-NN.webp`, where `NN` matches the curator-chosen `N` 1:1 — so renaming source images to that order at extract time makes the entire pipeline traceable: the curator sees `<Property Name> 22.jpg` in Finder, the brochure HTML references `images/<property-slug>-22.webp`, and a search across either resolves to the same photograph.

The rename is **not** a replacement of the original CDN-named files — those are preserved as-is for timestamp ordering and de-duplication. The renamed files are the same bytes (or a `cp`/`ln` of the originals), placed alongside, with the new `<Property Name> N.jpg` filenames.

Recipe:

```bash
cd <property_dir>/images
i=1
# Sort original CDN files by their embedded epoch timestamp (DSC01525_1727361503... → 1727361503)
for src in $(ls -1 | grep -E '^(DSC|DJI|ChatGPT-Image|Screenshot|IMG|iPhone|WhatsApp)' | \
              awk -F_ '{print $0, $NF}' | sort -k2 -n | awk '{print $1}'); do
  cp "$src" "<Property Name> ${i}.jpg"
  i=$((i+1))
done
```

The `<Property Name>` is taken from `property.json -> property_name` (see *Property name vs address* below). If the property has no distinct name (uncommon at this register), use the first part of the address (the street name).

Record the original-to-curator-name mapping in `property.json -> media.image_curator_map` so the rename is reversible:

```json
"media": {
  "image_curator_map": {
    "The Old Rectory 1.jpg": "DJI_0125_1777122816019.jpg",
    "The Old Rectory 2.jpg": "DJI_0115_1777122829462.jpg",
    "...": "..."
  }
}
```

### `floorplans/`

Every URL on the page pointing at a floorplan asset is in scope. On Loop-CRM tenants the pattern is `b-cdn.net/floorplans/<tenant-uuid>/<listing-id>/`; other portals serve from their own hosts. Listings typically ship one floorplan per floor (so 1–3 files for a UK home). Preserve original filenames.

### `epc/`

If the page links a PDF whose URL contains `epc` or whose link text mentions "EPC" / "Energy Performance Certificate", download it here. If the page only states a rating ("EPC rating: D") without linking a certificate, leave the folder empty and reflect the rating in `property.json`. If the rating is "TBC", record that exact string — do not guess.

### Map — extract or omit

**Maps are extracted, never synthesized.** If the listing doesn't supply one, the brochure ships without a map. No fallback to OSM static maps, no postcode-geocoded re-render via postcodes.io, no AI-generated map artwork. Postcode centroids can mislead about exact location, and the brochure should reflect what the agency actually publishes — not what the pipeline could imagine.

The capture is **provider-agnostic by design.** Estate agents embed maps in many ways: Google Maps iframes (common on Loop-CRM tenants), Mapbox or Leaflet `<div>`+canvas instantiations (custom builds), `<img>` static-map renders (used by some portal-style listings), Apple Maps embeds, custom-class wrappers — and that list will only grow. Don't hard-code a single CMS's markup. Instead: **find a map element by heuristic, screenshot it, save the bytes.** The provider's brand and tile style come along for the ride.

**Detect.** Render the listing page in a real browser (Playwright). Try these heuristics in order, taking the first non-empty hit:

1. **Iframes whose src points at a known map provider:** `iframe[src*="maps.google"], iframe[src*="google.com/maps"], iframe[src*="mapbox.com"], iframe[src*="apple.com/maps"], iframe[src*="bing.com/maps"], iframe[src*="openstreetmap"]`.
2. **Containers whose class or id contains "map":** `[class*="property-map"], [class*="location-map"], [class*="section__map"], [class*="listing-map"], [id*="property-map"], [id*="location-map"]`. Filter out obvious false positives by element size (the map should be at least 300×300 in rendered pixels).
3. **`<img>` elements whose src points at a static-map service:** `img[src*="staticmap"], img[src*="maps.googleapis.com/maps/api/staticmap"], img[src*="api.mapbox.com/styles"]`.
4. **Visual fallback** (only if 1–3 fail and the user has confirmed the listing should have one): scan the page for a square-ish element ≥ 400×400 px containing a `<canvas>` (Mapbox-GL / Leaflet rasters). This is best-effort — log what was found and let the user verify.

Take the first hit. If all heuristics return nothing, the listing has no map: stop, write `media.map: null`, do not synthesize.

**Capture.**

1. Navigate to the listing URL with Playwright.
2. Scroll the detected element into view (`element.scrollIntoViewIfNeeded()`).
3. Wait for tiles to load — `page.waitForLoadState('networkidle')` or a 2-second buffer after scroll, plus an extra 1-second beat for canvas-based maps that fade in.
4. Screenshot the **element**, not the page: `element.screenshot({path: '<property_dir>/images/map.png'})`. This captures whatever the agency renders, using whatever provider and API key they've already paid for, at the visual size their site presents.
5. Convert PNG → WebP via `cwebp -q 86 -m 6 -mt -resize 1500 0 map.png -o map.webp`. Target ~1500 px wide.
6. Discard the intermediate `map.png`; `map.webp` is the asset.

**Coordinates** (best-effort, optional). If the detected element is a Google Maps iframe whose `src` exposes `q=<lat>,<lon>` or `&center=<lat>,<lon>`, parse and record the coords under `address.geo`. If the element is a Mapbox iframe with `?center=lon,lat` in the URL, parse those. If the element is a div+canvas with no obvious URL, leave `address.geo: null` — coordinates aren't required for the map slot to render, since the screenshot is the asset. Coordinates are nice-to-have metadata, not gating data.

**Record in `property.json`.** Add two slots:

- Under `media`, set `map`: `"map.webp"` (filename, no path — the brochure resolves it inside `output/images/`).
- Under `address`, set `geo`: `{"latitude": <float>, "longitude": <float>, "source": "<provider-token>", "embed_url": "<src or null>"}` if coords were parseable; otherwise `null`. The `source` token is one of `google_maps_iframe`, `mapbox_iframe`, `apple_maps_iframe`, `static_map_image`, or `unknown` (when only a screenshot was captured).

**Absent case.** If no map element was detected, write `address.geo: null` and `media.map: null`. Do nothing else. The brochure step reads these nulls and removes its conditional map slot.

**Capture failure.** If a map element was detected but the screenshot fails (page rendered without tiles loading, rate-limited tile servers, transient network blip), record `media.map: null` and emit a `DONE_WITH_CONCERNS` listing the failure and the detected element — do not ship a half-cooked broken-image link. Any coords parsed from URLs may still be recorded so a later run can retry.

The rule, in one line: **extract or omit**.

### Property name vs address — they are not the same field

At the super-premium register, many properties have a **distinct name** that is separate from the postal address: *The Old Rectory*, *Mill House*, *Beacon House*. The brochure's cover hero is the property name; the subtitle is the address. The two must be captured separately at extraction time.

Detect the property name by:

1. Looking for "<Name>, <street>" patterns in the listing's title or H1 (e.g. "The Old Rectory, Mill Lane").
2. Looking for a name that is *not* a numbered street address (numbered addresses like "12 Mill Lane" don't have a separate name).
3. Cross-checking with a Sprift / Land Registry report — if the title is registered as "<Name> at <street>", that's the property name.

Record both in `property.json`:

```json
"property_name": "The Old Rectory",     // or null if the property has no distinct name
"address": {
  "line_1": "Mill Lane",
  "locality": "<Locality>",
  "...": "..."
}
```

When `property_name` is present, the brochure's cover hero uses it; the subtitle uses the address. When `property_name` is null, the cover hero uses `address.line_1`. Get this wrong at extract time and the brochure cover gets a street name where the property's actual name should be — a recurring failure mode this rule prevents.

### Material information from third-party reports — Sprift, Propalt, PropertyData

Beyond the listing page itself, three UK data sources provide the deeper Material Information fields (tenure, title number, council tax £, broadband, mobile signal, flood risk, conservation, planning) that buyers need under DMCCA 2024:

| Source | URL pattern | Best for |
|---|---|---|
| Sprift KFB report | `https://reports.sprift.com/property-report/<slug>/<id>` | Most fields, ready for human reading; needs Playwright to render (JS-loaded sections) |
| Sprift API | `https://sprift.com/data-and-api` | Programmatic access to the same data |
| Propalt API | `https://propalt.co.uk/product/api` | Material info bundle for compliance |
| PropertyData API | `https://propertydata.co.uk/api/land-registry` | Title number, sold history, deed restrictions |

If the user supplies a Sprift KFB URL, navigate it with Playwright (not `WebFetch` — sections are dynamically loaded), extract the published values, and record them in `property.json` under `material_info`:

```json
"material_info": {
  "title_number": "EX23685",
  "uprn": "100091281531",
  "local_authority": "Uttlesford",
  "council_tax_annual": "£3,890",
  "broadband_fttp_mbps": 1800,
  "mobile_coverage": { "EE": "Average", "Vodafone": "Average", "O2": "Good", "Three": "Average" },
  "flood_risk_surface_water": "Low",
  "flood_risk_rivers_seas": "Very low",
  "conservation_area": false,
  "restrictive_covenants": false,
  "...": "..."
}
```

The brochure step reads `material_info` and populates page 11's three-column disclosure grid. Fields not present remain "TBC" — never invent.

### Video tours and back-page QR codes

Estate-agent listings increasingly include a Giraffe360, Matterport, YouTube or Vimeo video tour. Record the URL in `property.json -> media.video_tours` (array). The brochure's back page shows two QR codes generated at brochure-assembly time:

- QR 1: the video tour URL
- QR 2: the listing URL itself

`property-brochure` calls `qrencode -t PNG -s 12 -m 2 -l M -o images/qr-video.png "<url>"` for each. The extract step doesn't generate QRs (they may be regenerated if URLs change), but it does need to capture the video URLs accurately so the brochure can build them.

### `property.json`

Machine-readable metadata. Required keys:

```json
{
  "source": {
    "url": "<full listing URL>",
    "listing_id": "<numeric id>",
    "tenant_id": "<uuid from CDN paths>",
    "scraped_on": "<YYYY-MM-DD>"
  },
  "property_name": "<distinct name or null>",
  "address": {
    "line_1": "...",
    "locality": "...",
    "postcode": "...",
    "geo": {
      "latitude": 51.793472,
      "longitude": 0.637936,
      "source": "google_maps_iframe",
      "embed_url": "https://www.google.com/maps/embed/v1/place?key=...&q=51.793472,0.637936"
    }
  },
  "price": {
    "amount": <integer GBP>,
    "currency": "GBP",
    "qualifier": "Offers Over | Guide Price | Asking Price | POA"
  },
  "specifications": {
    "property_type": "Detached House | ...",
    "bedrooms": <int>,
    "bathrooms": <int>,
    "reception_rooms": <int>,
    "tenure": "Freehold | Leasehold | null",
    "epc_rating": "A | B | ... | TBC",
    "council_tax_band": "A | B | ... | H",
    "age": "<string, optional>"
  },
  "key_features": ["...", "..."],
  "agent": {
    "name": "...",
    "agency": "...",
    "phone": "...",
    "email": "..."
  },
  "media": {
    "video_tours": ["<url>", "..."],
    "image_count": <int>,
    "floorplan_count": <int>,
    "epc_document": "<filename in epc/ or null>",
    "map": "<filename in images/ or null>",
    "brochure": null
  }
}
```

Unknown values are `null`, not `""` and not omitted. The downstream brochure skill relies on `null` to recognise "we know we don't have this" vs. a missing key (= "this wasn't extracted, look again").

`address.geo` and `media.map` follow the **extract or omit** rule documented under **Map — extract or omit**. `media.map` is populated when *any* map element on the listing was detected and screenshotted successfully (whatever provider — Google, Mapbox, Apple, custom, static-image). `address.geo` is populated only when coordinates were parseable from a recognised URL pattern; it can be `null` even when `media.map` is present (a div+canvas Mapbox map may yield a screenshot but no URL-embedded coords). Both are `null` when no map element was detected. Never synthesised from postcode geocoding or any other source.

### `description.md`

Plain Markdown, no frontmatter. Structure:

1. H1: full address.
2. One-line summary: price, beds, baths, reception rooms, type, council tax, EPC.
3. Source line: linked listing URL.
4. Agent line: name, agency, phone, email.
5. H2 "Property Description" — verbatim paragraphs from the listing's description section. Preserve paragraph breaks. Do not paraphrase. Strip HTML entities (`&rsquo;` → `'`, `&amp;` → `&`).
6. H2 "Video tours" — bulleted list of every video URL on the page (YouTube, Vimeo, Giraffe360, Matterport, etc.).
7. H2 "EPC and council tax" — both values exactly as shown.
8. H2 "Folder layout" — one-line description of what's in each subfolder, with the actual counts.

Capture rules:

- **Verbatim** for the description body. The agent's wording is the source of truth for the brochure copy.
- If the listing has no description text (rare), record `_No description provided on the listing._` and continue. Do not invent.
- If the listing's marketing footer ("WOULD YOU LIKE TO VIEW", "CAN WE HELP YOU TOO") is intermingled with the property description, **keep the property paragraphs and drop the agency boilerplate** — the brochure has its own back-page CTA.

## Image preview safety

**Do not `Read` any downloaded image whose longest edge exceeds 2000px** — it breaks the session by overrunning the multimodal encoding budget. Most DSLR (`DSC*`) and drone (`DJI_*`) photos from any premium listing are 6000–8000px and will trigger this. Measure with `sips -g pixelWidth -g pixelHeight <file>` before any `Read`, or skip the visual check entirely (filename, byte-size, and `sips` dimensions cover most "did the download work?" questions). See `make-brochure` for the full safety workflow including the `sips -Z 2000` downscale recipe.

## Process — high level

The skill orchestrates four capabilities, each a **what** not a **how**:

1. **Probe.** Fetch the URL once with `WebFetch` to get a summarised view (price, address, agent, image count). This is a cheap sanity check — if the summary disagrees with what you extract from raw HTML, the page is JS-rendered and you need a headless browser.
2. **Extract — tiered.** Try the static-HTML path first because it's cheap: pull the raw HTML and grep every image-asset URL (Loop-CRM tenants use `b-cdn.net/propertyimages/...` and `b-cdn.net/floorplans/...`; other portals serve from their own hosts), deduplicate, grep PDF URLs containing `epc`, extract any visible description container. **If the static probe returns zero image URLs, or the probed summary in step 1 reports content the static HTML does not contain (EPC band, floorplan thumbnail, video URL), escalate to the Playwright fallback in step 4b** — that's the first-class path for portals and tabbed UIs, not a conditional one. (Map detection is provider-agnostic and happens in step 4 below — don't try to parse map markup at this static-HTML stage.)
3. **Stage.** Create the four-folder structure under `<output-dir>/<slug>-<listing-id>/`. Download images and floorplans in parallel (`xargs -n1 -P8 curl -O -L`) — there are routinely 40–60 images per listing and serial downloads are slow.
4. **Capture map** (conditional, provider-agnostic). Drive Playwright to the listing URL and apply the heuristic detector under **Map — extract or omit**: try iframe-by-provider, then class/id-by-keyword, then static-map images, then visual fallback. On the first hit, scroll into view, wait for tiles, screenshot the **element** (not the page), convert to `map.webp` at ~1500 px wide. If no heuristic finds a map, skip — **extract or omit**, no fallback synthesis.
4b. **Traverse tabbed UIs and JS-rendered portals (first-class for non-static hosts).** Modern listing pages from Loop CRM tenants, Rightmove, Zoopla, OnTheMarket and others render top-level data in tabs — *Details / Floorplan / EPC / Virtual Tour / Map View / Book Viewing* — and the static HTML returned by `WebFetch` or `curl` often does not include the inactive-tab content. Whenever step 2's static probe came up short, drive a Playwright session against the listing URL and walk the tab list: detect elements with `role="tablist"`, `role="tab"`, or class/id substrings `tab` containing the keywords `EPC`, `Floor plan`, `Map`, `Virtual tour`, `Brochure`. For each tab found, `page.click(<selector>)`, `page.waitForLoadState('networkidle')`, then harvest the panel content. For the EPC tab specifically: scrape the certificate PDF/PNG link and the displayed current/potential scores into `specifications.epc_rating`, `specifications.epc_current_score`, `specifications.epc_potential_score`. For other tabs: extract what each contract field needs and skip the rest. The traversal is principled — never hard-code a single agency's or portal's tab markup as the sole detector; the same listing surface ships with different tab implementations across CMS tenants and portal layouts.
5. **Assemble.** Write `property.json` and `description.md`. Run a final consistency pass: image count in JSON matches files on disk, EPC rating in JSON matches the description.md line, agent contact in JSON matches the description.md header. `media.map` is populated iff a map screenshot landed on disk; `address.geo` is populated iff coordinates were parseable from a recognised URL pattern (and may legitimately be `null` even when `media.map` is set — see the schema notes).

The skill does not prescribe which HTTP tool to use. `curl` is fine for static-HTML hosts (most Loop-CRM tenants serve listing data inline); switch to Chrome DevTools MCP or Playwright for JS-rendered listing pages and portals — the contract on the output is unchanged.

## Reference standard

The format contract is defined by `property.json` (schema above) and `description.md` (structure above). If the caller has a prior conforming property pack on disk (under any `<brand_dir>/properties/<slug>-<id>/`), read its `property.json` and `description.md` to anchor the format. A delivered package that diverges substantially in shape from those is wrong, even if the content is accurate.

## Common mistakes

| Mistake | Why it's wrong |
|---|---|
| Using only the `WebFetch` summary | The summary lists "49 images" but does not return individual URLs. You must grep the raw HTML for filenames. |
| Stripping AI-generated renders from `images/` | They are part of the listing's marketing set. The brochure skill chooses which to use; the extractor's job is fidelity. |
| Renaming files to a `<slug>-NN.jpg` scheme on extraction | That's `property-brochure`'s job at the brochure-assembly step. Keep originals so the brochure skill can match by prefix and timestamp. |
| Inventing values for unknown specifications | `null` means "not on the listing". A guess at tenure or EPC introduces silent error into downstream Material Information compliance. |
| Including the agency's marketing boilerplate ("WOULD YOU LIKE TO VIEW", "CAN WE HELP YOU TOO") in `description.md` | The brochure has its own back page. Including it would duplicate text the brand designer is already authoring. |
| Skipping `floorplans/` because the listing only has one | One floorplan still goes in `floorplans/`, not `images/`. The folder layout is a contract — `property-brochure` looks for floorplans in exactly one place. |
| Saving `/tmp/listing_raw.html` or other working files inside the output directory | The directory is the deliverable. Working files get cleaned up. |
| Hardcoding the tenant UUID | On Loop-CRM tenants the UUID identifies the agency; different offices have different tenant IDs. Extract it from the actual URLs on the page rather than assuming a single value. |
| Treating "EPC: TBC" as a missing field | "TBC" is information — the agent is telling you the assessment hasn't been done. Record it verbatim. |
| Recording "EPC: TBC" when the listing's EPC tab actually shows a band but the static HTML did not | Modern listings tab-hide data behind a JS tab switcher; `WebFetch` / `curl` see only the active tab. If the listing visibly carries EPC content the static HTML lacks, drive Playwright per step 4b and harvest from the tab panel. "TBC" recorded from a missed-tab extract is a silent data-loss defect, not a faithful record. |
| Synthesizing a map when the listing has none | The rule is **extract or omit**. Postcode-centroid renders, OSM static maps, and AI-generated map artwork are all forbidden. If the heuristic detector returns nothing, write `media.map: null` and move on. |
| Hard-coding a single CMS's map markup (`section__property--map`, `<iframe src="google.com/maps">`) as the only detector | Each agent embeds maps differently — Loop CRM uses Google iframes, custom builds use Mapbox-GL canvases, others use static-map `<img>` tags. The detection order is heuristic and provider-agnostic by design. Hard-coding one breaks every other listing. |
| Trying to render a map provider's tiles ourselves with our own API key | The screenshot approach captures whatever the agency already pays for, with their attribution. Spinning up our own Mapbox/Google account introduces a billing relationship the user didn't ask for, and risks brand-mismatch with what the agency publishes. Screenshot the element; don't reach for an API. |

## Completion criteria

Report `DONE` only after:

- The directory `<slug>-<listing-id>/` exists with the three subfolders `images/`, `floorplans/`, `epc/` — and **no** pre-created `brochure/` (that's a legacy layout artefact; `property-brochure` writes to `<property_dir>/output/` directly).
- `property.json` and `description.md` are at the directory root.
- `images/` count matches `media.image_count` in `property.json`.
- `floorplans/` count matches `media.floorplan_count` in `property.json`.
- `epc/` is either empty (with rating "TBC" or only-rating-shown) or contains a downloaded certificate referenced by `media.epc_document`.
- `images/map.webp` exists **only if** the listing supplied a Google Maps iframe and the screenshot succeeded — `media.map` and `address.geo` agree (both populated or both `null`); no synthesised fallbacks.
- No `/tmp` files, no `.html` dumps, no `urls.txt` left behind.
- The source URL appears in `property.json.source.url` and on the source line of `description.md`.

If any of those are not true, report `DONE_WITH_CONCERNS` and list each gap.

## When NOT to use this skill

- The user wants a brochure produced from photos they already have on disk → skip extraction, go straight to `property-brochure`.
- The user wants a quick price/specs lookup, not the full asset bundle → use `WebFetch` directly. The folder structure is overkill.
- The user wants market data, comparables, or transaction history → that's a different research task; this skill is asset staging, not analysis.
