From 301986fb25b705cc0228bdae1d3e0d7826b2edba Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Fri, 27 Mar 2026 23:01:12 -0500 Subject: [PATCH] Add purpose-aware property assessor intake --- docs/property-assessor.md | 31 +- docs/web-automation.md | 40 ++- skills/property-assessor/SKILL.md | 19 +- .../examples/report-payload.example.json | 1 + skills/property-assessor/src/assessment.ts | 280 +++++++++++++++--- skills/property-assessor/src/cli.ts | 14 +- .../src/listing-discovery.ts | 66 +++++ skills/property-assessor/src/photo-review.ts | 56 ++++ skills/property-assessor/src/report-pdf.ts | 2 + .../tests/assessment.test.ts | 127 +++++++- .../tests/report-pdf.test.ts | 1 + skills/web-automation/.gitignore | 4 + skills/web-automation/SKILL.md | 11 +- skills/web-automation/scripts/har-discover.js | 139 +++++++++ skills/web-automation/scripts/package.json | 2 + .../web-automation/scripts/zillow-discover.js | 118 ++++++++ 16 files changed, 841 insertions(+), 70 deletions(-) create mode 100644 skills/property-assessor/src/listing-discovery.ts create mode 100644 skills/property-assessor/src/photo-review.ts create mode 100644 skills/web-automation/scripts/har-discover.js create mode 100644 skills/web-automation/scripts/zillow-discover.js diff --git a/docs/property-assessor.md b/docs/property-assessor.md index 627e60c..11dadbf 100644 --- a/docs/property-assessor.md +++ b/docs/property-assessor.md @@ -34,9 +34,9 @@ The wrapper script uses the skill-local Node dependencies under `node_modules/`. ## Commands ```bash -scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX 78418" -scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --recipient-email "buyer@example.com" -scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --recipient-email "buyer@example.com" --output /tmp/property-assessment.pdf +scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --assessment-purpose "investment property" +scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --assessment-purpose "investment property" --recipient-email "buyer@example.com" +scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --assessment-purpose "investment property" --recipient-email "buyer@example.com" --output /tmp/property-assessment.pdf scripts/property-assessor locate-public-records --address "4141 Whiteley Dr, Corpus Christi, TX 78418" scripts/property-assessor locate-public-records --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --parcel-id "14069438" @@ -61,23 +61,26 @@ Default operating sequence: ### `assess` ```bash -scripts/property-assessor assess --address "" -scripts/property-assessor assess --address "" --recipient-email "" +scripts/property-assessor assess --address "" --assessment-purpose "" +scripts/property-assessor assess --address "" --assessment-purpose "" --recipient-email "" ``` Current behavior: - starts from the address +- requires an assessment purpose for decision-grade analysis - automatically runs public-record / appraisal-district lookup +- automatically tries to discover Zillow and HAR listing URLs from the address when no listing URL is provided +- runs Zillow photo extraction first, then HAR as fallback when available - returns a structured preliminary report payload - asks for recipient email(s) before PDF generation - renders the fixed-template PDF when recipient email(s) are present Important limitation: -- this first implementation wires the address-first assessment spine -- it does not yet auto-run listing extraction, photo review, comps, or carry underwriting inside the helper itself -- those richer assessment steps are still governed by the skill workflow and the `web-automation` extractors +- this implementation now wires the address-first intake, purpose-aware framing, public-record lookup, listing discovery, and photo-source extraction +- it still does not perform full comp analysis, pricing judgment, or completed carry underwriting inside the helper itself +- those deeper decision steps are still governed by the skill workflow after the helper assembles the enriched payload ## Source priority @@ -205,8 +208,10 @@ For chat-driven runs, prefer file-based commands. Good: -- `scripts/property-assessor assess --address "..."` +- `scripts/property-assessor assess --address "..." --assessment-purpose "..."` - `node check-install.js` +- `node zillow-discover.js ""` +- `node har-discover.js ""` - `node zillow-photos.js ""` - `node har-photos.js ""` - `scripts/property-assessor locate-public-records --address "..."` @@ -304,13 +309,15 @@ npm test ```bash cd ~/.openclaw/workspace/skills/property-assessor -scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX 78418" +scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --assessment-purpose "investment property" ``` Expected shape: +- `needsAssessmentPurpose: false` - `needsRecipientEmails: true` - public-record / CAD jurisdiction included in the returned payload +- `photoReview.imageUrls` populated when Zillow or HAR extraction succeeds - no PDF generated yet - explicit message telling the operator to ask for target recipient email(s) @@ -332,7 +339,7 @@ Expected shape: ```bash cd ~/.openclaw/workspace/skills/property-assessor -scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --recipient-email "buyer@example.com" --output /tmp/property-assessment.pdf +scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --assessment-purpose "investment property" --recipient-email "buyer@example.com" --output /tmp/property-assessment.pdf ``` Expected result: @@ -368,8 +375,10 @@ When testing `property-assessor` itself, confirm the assessment: - starts from the address when available - uses Zillow first for photo extraction, HAR as fallback +- frames the analysis around the stated assessment purpose - uses official public-record jurisdiction links when available - does not treat listing geo IDs as assessor keys +- asks for assessment purpose if it was not provided - asks for recipient email(s) if they were not provided - renders the final report through the fixed PDF template once recipient email(s) are known diff --git a/docs/web-automation.md b/docs/web-automation.md index e2c36cc..e672701 100644 --- a/docs/web-automation.md +++ b/docs/web-automation.md @@ -15,6 +15,7 @@ Automated web browsing and scraping using Playwright-compatible CloakBrowser, wi - Use `node skills/web-automation/scripts/extract.js ""` for one-shot extraction from a single URL - Use `npx tsx scrape.ts ...` for markdown scraping modes - Use `npx tsx browse.ts ...`, `auth.ts`, or `flow.ts` for interactive or authenticated flows +- Use `node skills/web-automation/scripts/zillow-discover.js ""` or `har-discover.js` to resolve a real-estate listing URL from an address - Use `node skills/web-automation/scripts/zillow-photos.js ""` or `har-photos.js` for real-estate photo extraction before attempting generic gallery automation ## Requirements @@ -93,6 +94,7 @@ Notes: - If matching is inconsistent, replace `~/.openclaw/...` with the full absolute path for the machine. - Keep the allowlist scoped to the main agent unless there is a clear reason to widen it. - Prefer file-based commands like `node check-install.js`, `node zillow-photos.js ...`, and `node har-photos.js ...` over inline `node -e ...`. Inline interpreter eval is more likely to trigger approval friction. +- The same applies to `zillow-discover.js` and `har-discover.js`: keep discovery file-based, not inline. ## Common commands @@ -104,6 +106,12 @@ node check-install.js # One-shot JSON extraction node skills/web-automation/scripts/extract.js "https://example.com" +# Zillow listing discovery from address +node skills/web-automation/scripts/zillow-discover.js "4141 Whiteley Dr, Corpus Christi, TX 78418" + +# HAR listing discovery from address +node skills/web-automation/scripts/har-discover.js "4141 Whiteley Dr, Corpus Christi, TX 78418" + # Zillow photo extraction node skills/web-automation/scripts/zillow-photos.js "https://www.zillow.com/homedetails/..." @@ -123,9 +131,35 @@ npx tsx auth.ts --url "https://example.com/login" npx tsx flow.ts --instruction 'go to https://search.fiorinis.com then type "pippo" then press enter then wait 2s' ``` -## Real-estate photo extraction +## Real-estate listing discovery and photo extraction -Use the dedicated Zillow and HAR extractors before trying a free-form gallery flow. +Use the dedicated Zillow and HAR discovery/photo commands before trying a free-form gallery flow. + +### Zillow discovery + +```bash +cd ~/.openclaw/workspace/skills/web-automation/scripts +node zillow-discover.js "4141 Whiteley Dr, Corpus Christi, TX 78418" +``` + +What it does: +- opens the Zillow address URL with CloakBrowser +- resolves directly to a property page when Zillow supports the address slug +- otherwise looks for a `homedetails` listing link in the rendered page +- returns the discovered listing URL as JSON + +### HAR discovery + +```bash +cd ~/.openclaw/workspace/skills/web-automation/scripts +node har-discover.js "4141 Whiteley Dr, Corpus Christi, TX 78418" +``` + +What it does: +- opens the HAR address search page +- looks for a confident `homedetail` match in rendered results +- returns the discovered listing URL when HAR exposes a strong enough match +- returns `listingUrl: null` when HAR discovery is not confident enough ### Zillow @@ -169,6 +203,8 @@ From `skills/web-automation/scripts`: ```bash node check-install.js npm run test:photos +node zillow-discover.js "" +node har-discover.js "" node zillow-photos.js "" node har-photos.js "" ``` diff --git a/skills/property-assessor/SKILL.md b/skills/property-assessor/SKILL.md index 90d0feb..8ce4829 100644 --- a/skills/property-assessor/SKILL.md +++ b/skills/property-assessor/SKILL.md @@ -12,7 +12,10 @@ Start from the property address when possible. Treat listing URLs as supporting Accept any of: - a street address - one or more listing URLs -- an address plus user constraints such as investment only, owner-occupant, long-term rental, STR, or distance to a target location +- an address plus the reason for the assessment, such as investment property, vacation home, owner-occupant, long-term rental, STR, or housing for a child in college + +The assessment purpose is required for a decision-grade result. +If the user does not say why they want the property assessed, stop and ask before finalizing the analysis. ## Core workflow @@ -73,14 +76,18 @@ Quick command summary: cd ~/.openclaw/workspace/skills/property-assessor npm install scripts/property-assessor assess --address "" -scripts/property-assessor assess --address "" --recipient-email "" +scripts/property-assessor assess --address "" --assessment-purpose "" +scripts/property-assessor assess --address "" --assessment-purpose "" --recipient-email "" scripts/property-assessor locate-public-records --address "" scripts/property-assessor render-report --input "" --output "" ``` `assess` is the address-first entrypoint. It should: +- require the assessment purpose - resolve official public-record jurisdiction automatically from the address -- build the report payload skeleton +- try to discover Zillow and HAR listing URLs from the address when no listing URL is provided +- run the approval-safe Zillow/HAR photo extractor chain automatically +- build a purpose-aware report payload - stop and ask for recipient email(s) before rendering the PDF - render the PDF only after recipient email(s) are known @@ -112,6 +119,12 @@ scripts/property-assessor assess --address "" ``` This command should automatically include the public-record jurisdiction result in the returned assessment payload. +When `--assessment-purpose` is present, it should also: +- frame the analysis around that stated purpose +- try Zillow discovery from the address +- try HAR discovery from the address +- run Zillow photo extraction first when available, then HAR as fallback +- include the extracted `imageUrls` in `photoReview` when successful This command currently: - resolves the address through the official Census geocoder diff --git a/skills/property-assessor/examples/report-payload.example.json b/skills/property-assessor/examples/report-payload.example.json index d9823c4..5bab7fb 100644 --- a/skills/property-assessor/examples/report-payload.example.json +++ b/skills/property-assessor/examples/report-payload.example.json @@ -2,6 +2,7 @@ "recipientEmails": [ "buyer@example.com" ], + "assessmentPurpose": "investment property", "reportTitle": "Property Assessment Report", "subtitle": "Sample property assessment payload", "subjectProperty": { diff --git a/skills/property-assessor/src/assessment.ts b/skills/property-assessor/src/assessment.ts index 1a76956..d7bd69f 100644 --- a/skills/property-assessor/src/assessment.ts +++ b/skills/property-assessor/src/assessment.ts @@ -1,11 +1,14 @@ import os from "node:os"; import path from "node:path"; +import { discoverListingSources, type ListingDiscoveryResult } from "./listing-discovery.js"; +import { extractPhotoData, type PhotoExtractionResult, type PhotoSource } from "./photo-review.js"; import { resolvePublicRecords, type PublicRecordsResolution } from "./public-records.js"; import { renderReportPdf, type ReportPayload } from "./report-pdf.js"; export interface AssessPropertyOptions { address: string; + assessmentPurpose?: string; recipientEmails?: string[] | string; output?: string; parcelId?: string; @@ -15,16 +18,30 @@ export interface AssessPropertyOptions { export interface AssessPropertyResult { ok: true; + needsAssessmentPurpose: boolean; needsRecipientEmails: boolean; message: string; outputPath: string | null; - reportPayload: ReportPayload; - publicRecords: PublicRecordsResolution; + reportPayload: ReportPayload | null; + publicRecords: PublicRecordsResolution | null; } interface AssessPropertyDeps { resolvePublicRecordsFn?: typeof resolvePublicRecords; renderReportPdfFn?: typeof renderReportPdf; + discoverListingSourcesFn?: typeof discoverListingSources; + extractPhotoDataFn?: typeof extractPhotoData; +} + +interface PurposeGuidance { + label: string; + snapshot: string; + like: string; + caution: string; + comp: string; + carry: string; + diligence: string; + verdict: string; } function asStringArray(value: unknown): string[] { @@ -95,36 +112,206 @@ function buildPublicRecordLinks( return links; } +function normalizePurpose(value: string): string { + return value.trim().replace(/\s+/g, " "); +} + +function getPurposeGuidance(purpose: string): PurposeGuidance { + const normalized = purpose.toLowerCase(); + + if (/(invest|rental|cash[\s-]?flow|income|flip|str|airbnb)/i.test(normalized)) { + return { + label: purpose, + snapshot: `Purpose fit: evaluate this as an income property first, not a generic owner-occupant home.`, + like: "Purpose-aligned upside will depend on durable rent support, manageable make-ready, and exit liquidity.", + caution: "An investment property is not attractive just because the address clears basic public-record checks; rent support and margin still decide the deal.", + comp: "Comp work should focus on resale liquidity and true rent support for an income property, not only nearby asking prices.", + carry: "Carry view should underwrite this as an income property with taxes, insurance, HOA, maintenance, vacancy, and management friction.", + diligence: "Confirm realistic rent, reserve for turns and repairs, and whether the building/submarket has enough liquidity for an investment property exit.", + verdict: `Assessment purpose: ${purpose}. Do not issue a buy/pass conclusion until the property clears rent support, carry, and make-ready standards for an investment property.` + }; + } + + if (/(vacation|second home|weekend|personal use|beach|getaway)/i.test(normalized)) { + return { + label: purpose, + snapshot: "Purpose fit: evaluate this as a vacation home with personal-use fit and carrying-cost tolerance in mind.", + like: "A vacation-home decision can justify paying for lifestyle fit, but only if ongoing friction is acceptable.", + caution: "Vacation home ownership can hide real recurring cost drag when insurance, HOA, storm exposure, and deferred maintenance are under-modeled.", + comp: "Comp work should focus on lifestyle alternatives, micro-location quality, and whether the premium over substitutes is defensible for a vacation home.", + carry: "Carry view should stress-test second-home costs, especially insurance, HOA, special assessments, and low-utilization months.", + diligence: "Confirm rules, reserves, storm or flood exposure, and whether the property still makes sense if usage ends up lower than expected.", + verdict: `Assessment purpose: ${purpose}. The final call should weigh personal-use fit against ongoing friction, not just headline list price.` + }; + } + + if (/(daughter|son|college|student|school|campus)/i.test(normalized)) { + return { + label: purpose, + snapshot: "Purpose fit: evaluate this as practical housing for a student, with safety, commute, durability, and oversight burden in mind.", + like: "This purpose is best served by simple logistics, durable finishes, and a layout that is easy to live in and maintain.", + caution: "Student-use housing can become a bad fit when commute friction, fragile finishes, or management burden are underestimated.", + comp: "Comp work should emphasize campus proximity, day-to-day practicality, and whether comparable student-friendly options exist at lower all-in cost.", + carry: "Carry view should account for parent-risk factors, furnishing/setup costs, upkeep burden, and whether renting may still be the lower-risk option.", + diligence: "Confirm commute, safety, parking, roommate practicality if relevant, and whether the property is easy to maintain from a distance.", + verdict: `Assessment purpose: ${purpose}. The final recommendation should prioritize practicality, safety, and management burden over generic resale talking points.` + }; + } + + return { + label: purpose, + snapshot: `Purpose fit: ${purpose}. The final recommendation should be explicitly tested against that goal.`, + like: "The assessment should stay anchored to the stated purpose rather than defaulting to generic market commentary.", + caution: "Even a clean public-record and photo intake is not enough if the property does not fit the stated purpose.", + comp: "Comp work should compare against alternatives that solve the same purpose, not just nearby listings.", + carry: "Carry view should reflect the stated purpose and the real friction it implies.", + diligence: "Purpose-specific diligence should be listed explicitly before a final buy/pass/offer recommendation.", + verdict: `Assessment purpose: ${purpose}. The final conclusion must be explained in terms of that stated objective.` + }; +} + +function inferSourceFromUrl(rawUrl: string): PhotoSource | null { + try { + const url = new URL(rawUrl); + const host = url.hostname.toLowerCase(); + if (host.includes("zillow.com")) return "zillow"; + if (host.includes("har.com")) return "har"; + return null; + } catch { + return null; + } +} + +async function resolvePhotoReview( + options: AssessPropertyOptions, + discoverListingSourcesFn: typeof discoverListingSources, + extractPhotoDataFn: typeof extractPhotoData +): Promise<{ + listingUrls: Array<{ label: string; url: string }>; + photoReview: Record; +}> { + const attempts: string[] = []; + const listingUrls: Array<{ label: string; url: string }> = []; + + const addListingUrl = (label: string, url: string | null | undefined): void => { + if (!url) return; + if (!listingUrls.some((item) => item.url === url)) { + listingUrls.push({ label, url }); + } + }; + + let zillowUrl: string | null = null; + let harUrl: string | null = null; + + if (options.listingSourceUrl) { + const explicitSource = inferSourceFromUrl(options.listingSourceUrl); + addListingUrl("Explicit Listing Source", options.listingSourceUrl); + if (explicitSource === "zillow") { + zillowUrl = options.listingSourceUrl; + attempts.push(`Using explicit Zillow listing URL: ${options.listingSourceUrl}`); + } else if (explicitSource === "har") { + harUrl = options.listingSourceUrl; + attempts.push(`Using explicit HAR listing URL: ${options.listingSourceUrl}`); + } else { + attempts.push( + `Explicit listing URL was provided but is not a supported Zillow/HAR photo source: ${options.listingSourceUrl}` + ); + } + } + + if (!zillowUrl && !harUrl) { + const discovered = await discoverListingSourcesFn(options.address); + attempts.push(...discovered.attempts); + zillowUrl = discovered.zillowUrl; + harUrl = discovered.harUrl; + addListingUrl("Discovered Zillow Listing", zillowUrl); + addListingUrl("Discovered HAR Listing", harUrl); + } + + const candidates: Array<{ source: PhotoSource; url: string }> = []; + if (zillowUrl) candidates.push({ source: "zillow", url: zillowUrl }); + if (harUrl) candidates.push({ source: "har", url: harUrl }); + + let extracted: PhotoExtractionResult | null = null; + for (const candidate of candidates) { + try { + const result = await extractPhotoDataFn(candidate.source, candidate.url); + extracted = result; + attempts.push( + `${candidate.source} photo extraction succeeded with ${result.photoCount} photos.` + ); + if (result.notes.length) { + attempts.push(...result.notes); + } + break; + } catch (error) { + attempts.push( + `${candidate.source} photo extraction failed: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + if (extracted) { + return { + listingUrls, + photoReview: { + status: "completed", + source: extracted.source, + photoCount: extracted.photoCount, + expectedPhotoCount: extracted.expectedPhotoCount ?? null, + imageUrls: extracted.imageUrls, + attempts, + summary: + "Photo URLs were collected successfully. A decision-grade condition read still requires reviewing the extracted image set.", + } + }; + } + + if (!candidates.length) { + attempts.push( + "No supported Zillow or HAR listing URL was available for photo extraction." + ); + } + + return { + listingUrls, + photoReview: { + status: "not completed", + source: candidates.length ? "listing source attempted" : "no supported listing source", + attempts, + summary: + "Condition review is incomplete until Zillow or HAR photos are extracted and inspected." + } + }; +} + export function buildAssessmentReportPayload( options: AssessPropertyOptions, - publicRecords: PublicRecordsResolution + publicRecords: PublicRecordsResolution, + listingUrls: Array<{ label: string; url: string }>, + photoReview: Record ): ReportPayload { const recipientEmails = asStringArray(options.recipientEmails); const matchedAddress = publicRecords.matchedAddress || options.address; const publicRecordLinks = buildPublicRecordLinks(publicRecords); const sourceLinks = [...publicRecordLinks]; + const purpose = normalizePurpose(options.assessmentPurpose || ""); + const purposeGuidance = getPurposeGuidance(purpose); - pushLink(sourceLinks, "Listing Source", options.listingSourceUrl); + for (const item of listingUrls) { + pushLink(sourceLinks, item.label, item.url); + } const jurisdiction = publicRecords.county.name && publicRecords.appraisalDistrict ? `${publicRecords.county.name} Appraisal District` : publicRecords.county.name || publicRecords.state.name || "Official public-record jurisdiction"; - const photoAttempts = options.listingSourceUrl - ? [ - `Listing source captured: ${options.listingSourceUrl}`, - "Photo review has not been run yet. Use the approval-safe Zillow/HAR extractor flow before making condition claims." - ] - : [ - "No listing source URL was provided to the assess helper.", - "Photo review has not been run yet. Use the approval-safe Zillow/HAR extractor flow before making condition claims." - ]; - return { recipientEmails, + assessmentPurpose: purposeGuidance.label, reportTitle: "Property Assessment Report", - subtitle: "Preliminary address-first intake with public-record enrichment.", + subtitle: "Address-first intake with public-record enrichment and approval-safe photo-source orchestration.", subjectProperty: { address: matchedAddress, county: publicRecords.county.name || "N/A", @@ -134,38 +321,32 @@ export function buildAssessmentReportPayload( verdict: { decision: "pending", fairValueRange: "Not established", - offerGuidance: - "Preliminary intake only. Official public-record jurisdiction is identified, but listing, photo, comp, and carry analysis still required before a buy/pass/offer conclusion." + offerGuidance: purposeGuidance.verdict }, snapshot: [ `Matched address: ${matchedAddress}`, `Jurisdiction anchor: ${publicRecords.county.name || "Unknown county"}, ${publicRecords.state.code || publicRecords.state.name || "Unknown state"}.`, - publicRecords.geoid ? `Census block GEOID: ${publicRecords.geoid}.` : "Census block GEOID not returned." + publicRecords.geoid ? `Census block GEOID: ${publicRecords.geoid}.` : "Census block GEOID not returned.", + purposeGuidance.snapshot ], whatILike: [ "Address-first flow avoids treating listing-site geo IDs as assessor record keys.", publicRecords.appraisalDistrict ? "Official appraisal-district contact and website were identified from public records." - : "Official public-record geography was identified." + : "Official public-record geography was identified.", + purposeGuidance.like ], whatIDontLike: [ - "This first-pass assess helper does not yet include listing facts, comp analysis, or a completed photo review.", - "Do not make valuation or condition claims from this preliminary output alone." + "This assess helper still needs listing facts, comp analysis, and a human/model review of the extracted photo set before any final valuation claim.", + purposeGuidance.caution ], - compView: [ - "Comp analysis not yet run. Pull same-building or nearby comps before setting fair value." + compView: [purposeGuidance.comp], + carryView: [purposeGuidance.carry], + risksAndDiligence: [ + ...publicRecords.lookupRecommendations, + purposeGuidance.diligence ], - carryView: [ - "Carry-cost underwriting not yet run. Add taxes, HOA, insurance, maintenance, and vacancy assumptions before decisioning." - ], - risksAndDiligence: publicRecords.lookupRecommendations, - photoReview: { - status: "not completed", - source: options.listingSourceUrl ? "listing source pending review" : "no listing source provided", - attempts: photoAttempts, - summary: - "Condition review is incomplete until listing photos are actually extracted and inspected." - }, + photoReview, publicRecords: { jurisdiction, accountNumber: options.parcelId || publicRecords.sourceIdentifierHints.parcelId, @@ -184,8 +365,24 @@ export async function assessProperty( options: AssessPropertyOptions, deps: AssessPropertyDeps = {} ): Promise { + const purpose = normalizePurpose(options.assessmentPurpose || ""); + if (!purpose) { + return { + ok: true, + needsAssessmentPurpose: true, + needsRecipientEmails: false, + message: + "Missing assessment purpose. Stop and ask the user why they want this property assessed before producing a decision-grade analysis.", + outputPath: null, + reportPayload: null, + publicRecords: null + }; + } + const resolvePublicRecordsFn = deps.resolvePublicRecordsFn || resolvePublicRecords; const renderReportPdfFn = deps.renderReportPdfFn || renderReportPdf; + const discoverListingSourcesFn = deps.discoverListingSourcesFn || discoverListingSources; + const extractPhotoDataFn = deps.extractPhotoDataFn || extractPhotoData; const publicRecords = await resolvePublicRecordsFn(options.address, { parcelId: options.parcelId, @@ -193,12 +390,24 @@ export async function assessProperty( listingSourceUrl: options.listingSourceUrl }); - const reportPayload = buildAssessmentReportPayload(options, publicRecords); + const photoResolution = await resolvePhotoReview( + { ...options, assessmentPurpose: purpose }, + discoverListingSourcesFn, + extractPhotoDataFn + ); + + const reportPayload = buildAssessmentReportPayload( + { ...options, assessmentPurpose: purpose }, + publicRecords, + photoResolution.listingUrls, + photoResolution.photoReview + ); const recipientEmails = asStringArray(options.recipientEmails); if (!recipientEmails.length) { return { ok: true, + needsAssessmentPurpose: false, needsRecipientEmails: true, message: "Missing target email. Stop and ask the user for target email address(es) before generating or sending the property assessment PDF.", @@ -218,6 +427,7 @@ export async function assessProperty( const renderedPath = await renderReportPdfFn(reportPayload, outputPath); return { ok: true, + needsAssessmentPurpose: false, needsRecipientEmails: false, message: `Property assessment PDF rendered: ${renderedPath}`, outputPath: renderedPath, diff --git a/skills/property-assessor/src/cli.ts b/skills/property-assessor/src/cli.ts index 10d4942..64939e8 100644 --- a/skills/property-assessor/src/cli.ts +++ b/skills/property-assessor/src/cli.ts @@ -9,7 +9,7 @@ import { ReportValidationError, loadReportPayload, renderReportPdf } from "./rep function usage(): void { process.stdout.write(`property-assessor\n Commands: - assess --address "
" [--recipient-email ""] [--output ""] [--parcel-id ""] [--listing-geo-id ""] [--listing-source-url ""] + assess --address "
" --assessment-purpose "" [--recipient-email ""] [--output ""] [--parcel-id ""] [--listing-geo-id ""] [--listing-source-url ""] locate-public-records --address "
" [--parcel-id ""] [--listing-geo-id ""] [--listing-source-url ""] render-report --input "" --output "" `); @@ -17,7 +17,16 @@ Commands: async function main(): Promise { const argv = minimist(process.argv.slice(2), { - string: ["address", "parcel-id", "listing-geo-id", "listing-source-url", "input", "output"], + string: [ + "address", + "assessment-purpose", + "recipient-email", + "parcel-id", + "listing-geo-id", + "listing-source-url", + "input", + "output" + ], alias: { h: "help" } @@ -35,6 +44,7 @@ async function main(): Promise { } const payload = await assessProperty({ address: argv.address, + assessmentPurpose: argv["assessment-purpose"], recipientEmails: argv["recipient-email"], output: argv.output, parcelId: argv["parcel-id"], diff --git a/skills/property-assessor/src/listing-discovery.ts b/skills/property-assessor/src/listing-discovery.ts new file mode 100644 index 0000000..488369b --- /dev/null +++ b/skills/property-assessor/src/listing-discovery.ts @@ -0,0 +1,66 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export interface ListingDiscoveryResult { + attempts: string[]; + zillowUrl: string | null; + harUrl: string | null; +} + +function parseJsonOutput(raw: string, context: string): any { + const text = raw.trim(); + if (!text) { + throw new Error(`${context} produced no JSON output.`); + } + return JSON.parse(text); +} + +async function runDiscoveryScript( + scriptName: string, + address: string +): Promise<{ listingUrl: string | null; attempts: string[] }> { + const scriptPath = `/Users/stefano/.openclaw/workspace/skills/web-automation/scripts/${scriptName}`; + const { stdout } = await execFileAsync(process.execPath, [scriptPath, address], { + timeout: 120000, + maxBuffer: 2 * 1024 * 1024 + }); + const payload = parseJsonOutput(stdout, scriptName); + return { + listingUrl: typeof payload.listingUrl === "string" && payload.listingUrl.trim() ? payload.listingUrl.trim() : null, + attempts: Array.isArray(payload.attempts) ? payload.attempts.map(String) : [] + }; +} + +export async function discoverListingSources(address: string): Promise { + const attempts: string[] = []; + let zillowUrl: string | null = null; + let harUrl: string | null = null; + + try { + const result = await runDiscoveryScript("zillow-discover.js", address); + zillowUrl = result.listingUrl; + attempts.push(...result.attempts); + } catch (error) { + attempts.push( + `Zillow discovery failed: ${error instanceof Error ? error.message : String(error)}` + ); + } + + try { + const result = await runDiscoveryScript("har-discover.js", address); + harUrl = result.listingUrl; + attempts.push(...result.attempts); + } catch (error) { + attempts.push( + `HAR discovery failed: ${error instanceof Error ? error.message : String(error)}` + ); + } + + return { + attempts, + zillowUrl, + harUrl + }; +} diff --git a/skills/property-assessor/src/photo-review.ts b/skills/property-assessor/src/photo-review.ts new file mode 100644 index 0000000..a3d24ad --- /dev/null +++ b/skills/property-assessor/src/photo-review.ts @@ -0,0 +1,56 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export type PhotoSource = "zillow" | "har"; + +export interface PhotoExtractionResult { + source: PhotoSource; + requestedUrl: string; + finalUrl?: string; + expectedPhotoCount?: number | null; + complete?: boolean; + photoCount: number; + imageUrls: string[]; + notes: string[]; +} + +export interface PhotoReviewResolution { + review: Record; + discoveredListingUrls: Array<{ label: string; url: string }>; +} + +function parseJsonOutput(raw: string, context: string): any { + const text = raw.trim(); + if (!text) { + throw new Error(`${context} produced no JSON output.`); + } + return JSON.parse(text); +} + +export async function extractPhotoData( + source: PhotoSource, + url: string +): Promise { + const scriptMap: Record = { + zillow: "zillow-photos.js", + har: "har-photos.js" + }; + const scriptPath = `/Users/stefano/.openclaw/workspace/skills/web-automation/scripts/${scriptMap[source]}`; + const { stdout } = await execFileAsync(process.execPath, [scriptPath, url], { + timeout: 180000, + maxBuffer: 5 * 1024 * 1024 + }); + const payload = parseJsonOutput(stdout, scriptMap[source]); + return { + source, + requestedUrl: String(payload.requestedUrl || url), + finalUrl: typeof payload.finalUrl === "string" ? payload.finalUrl : undefined, + expectedPhotoCount: typeof payload.expectedPhotoCount === "number" ? payload.expectedPhotoCount : null, + complete: Boolean(payload.complete), + photoCount: Number(payload.photoCount || 0), + imageUrls: Array.isArray(payload.imageUrls) ? payload.imageUrls.map(String) : [], + notes: Array.isArray(payload.notes) ? payload.notes.map(String) : [] + }; +} diff --git a/skills/property-assessor/src/report-pdf.ts b/skills/property-assessor/src/report-pdf.ts index 0559bde..d26f113 100644 --- a/skills/property-assessor/src/report-pdf.ts +++ b/skills/property-assessor/src/report-pdf.ts @@ -6,6 +6,7 @@ export class ReportValidationError extends Error {} export interface ReportPayload { recipientEmails?: string[] | string; + assessmentPurpose?: string; reportTitle?: string; subtitle?: string; generatedAt?: string; @@ -258,6 +259,7 @@ export async function renderReportPdf( drawKeyValueTable(doc, [ ["Address", String(subject.address || "N/A")], + ["Assessment Purpose", String(payload.assessmentPurpose || "N/A")], ["Ask / Last Price", currency(subject.listingPrice)], ["Type", String(subject.propertyType || "N/A")], ["Beds / Baths", `${subject.beds ?? "N/A"} / ${subject.baths ?? "N/A"}`], diff --git a/skills/property-assessor/tests/assessment.test.ts b/skills/property-assessor/tests/assessment.test.ts index 56542c2..157c1a2 100644 --- a/skills/property-assessor/tests/assessment.test.ts +++ b/skills/property-assessor/tests/assessment.test.ts @@ -47,32 +47,120 @@ const samplePublicRecords: PublicRecordsResolution = { } }; -test("assessProperty auto-enriches public-record data from address and asks for recipient email", async () => { +test("assessProperty asks for assessment purpose before building a decision-grade assessment", async () => { const result = await assessProperty( { - address: "4141 Whiteley Dr, Corpus Christi, TX 78418", - listingSourceUrl: "https://www.zillow.com/homedetails/example", - listingGeoId: "233290", - parcelId: "14069438" - }, - { - resolvePublicRecordsFn: async () => samplePublicRecords + address: "4141 Whiteley Dr, Corpus Christi, TX 78418" } ); assert.equal(result.ok, true); + assert.equal(result.needsAssessmentPurpose, true); + assert.equal(result.needsRecipientEmails, false); + assert.equal(result.outputPath, null); + assert.match(result.message, /assessment purpose/i); + assert.equal(result.reportPayload, null); +}); + +test("assessProperty auto-discovers listing sources, runs Zillow photos first, and asks for recipient email", async () => { + const result = await assessProperty( + { + address: "4141 Whiteley Dr, Corpus Christi, TX 78418", + assessmentPurpose: "investment property", + listingGeoId: "233290", + parcelId: "14069438" + }, + { + resolvePublicRecordsFn: async () => samplePublicRecords, + discoverListingSourcesFn: async () => ({ + attempts: [ + "Zillow discovery located a property page from the address.", + "HAR discovery located a property page from the address." + ], + zillowUrl: + "https://www.zillow.com/homedetails/4141-Whiteley-Dr-Corpus-Christi-TX-78418/2103723704_zpid/", + harUrl: + "https://www.har.com/homedetail/4141-whiteley-dr-corpus-christi-tx-78418/14069438" + }), + extractPhotoDataFn: async (source, url) => ({ + source, + requestedUrl: url, + finalUrl: url, + expectedPhotoCount: 29, + complete: true, + photoCount: 29, + imageUrls: ["https://photos.example/1.jpg", "https://photos.example/2.jpg"], + notes: [`${source} extractor succeeded.`] + }) + } + ); + + assert.equal(result.ok, true); + assert.equal(result.needsAssessmentPurpose, false); assert.equal(result.needsRecipientEmails, true); assert.equal(result.outputPath, null); assert.match(result.message, /target email/i); - assert.equal(result.reportPayload.subjectProperty?.address, samplePublicRecords.matchedAddress); - assert.equal(result.reportPayload.publicRecords?.jurisdiction, "Nueces County Appraisal District"); - assert.equal(result.reportPayload.publicRecords?.accountNumber, "14069438"); - assert.equal(result.reportPayload.photoReview?.status, "not completed"); + assert.equal(result.reportPayload?.subjectProperty?.address, samplePublicRecords.matchedAddress); + assert.equal(result.reportPayload?.publicRecords?.jurisdiction, "Nueces County Appraisal District"); + assert.equal(result.reportPayload?.publicRecords?.accountNumber, "14069438"); + assert.equal(result.reportPayload?.photoReview?.status, "completed"); + assert.equal(result.reportPayload?.photoReview?.source, "zillow"); + assert.equal(result.reportPayload?.photoReview?.photoCount, 29); assert.match( - String(result.reportPayload.verdict?.offerGuidance), - /listing, photo, comp, and carry analysis still required/i + String(result.reportPayload?.verdict?.offerGuidance), + /investment property/i + ); + assert.match( + String(result.reportPayload?.carryView?.[0]), + /income property/i + ); + assert.deepEqual(result.reportPayload?.recipientEmails, []); +}); + +test("assessProperty falls back to HAR when Zillow photo extraction fails", async () => { + const result = await assessProperty( + { + address: "4141 Whiteley Dr, Corpus Christi, TX 78418", + assessmentPurpose: "vacation home" + }, + { + resolvePublicRecordsFn: async () => samplePublicRecords, + discoverListingSourcesFn: async () => ({ + attempts: ["Address-based discovery found Zillow and HAR candidates."], + zillowUrl: + "https://www.zillow.com/homedetails/4141-Whiteley-Dr-Corpus-Christi-TX-78418/2103723704_zpid/", + harUrl: + "https://www.har.com/homedetail/4141-whiteley-dr-corpus-christi-tx-78418/14069438" + }), + extractPhotoDataFn: async (source, url) => { + if (source === "zillow") { + throw new Error(`zillow failed for ${url}`); + } + return { + source, + requestedUrl: url, + finalUrl: url, + expectedPhotoCount: 29, + complete: true, + photoCount: 29, + imageUrls: ["https://photos.har.example/1.jpg"], + notes: ["HAR extractor succeeded after Zillow failed."] + }; + } + } + ); + + assert.equal(result.ok, true); + assert.equal(result.reportPayload?.photoReview?.status, "completed"); + assert.equal(result.reportPayload?.photoReview?.source, "har"); + assert.match( + String(result.reportPayload?.photoReview?.attempts?.join(" ")), + /zillow/i + ); + assert.match( + String(result.reportPayload?.verdict?.offerGuidance), + /vacation home/i ); - assert.deepEqual(result.reportPayload.recipientEmails, []); }); test("assessProperty renders a PDF when recipient email is present", async () => { @@ -80,15 +168,22 @@ test("assessProperty renders a PDF when recipient email is present", async () => const result = await assessProperty( { address: "4141 Whiteley Dr, Corpus Christi, TX 78418", + assessmentPurpose: "rental for my daughter in college", recipientEmails: ["buyer@example.com"], output: outputPath }, { - resolvePublicRecordsFn: async () => samplePublicRecords + resolvePublicRecordsFn: async () => samplePublicRecords, + discoverListingSourcesFn: async () => ({ + attempts: ["No listing sources discovered from the address."], + zillowUrl: null, + harUrl: null + }) } ); assert.equal(result.ok, true); + assert.equal(result.needsAssessmentPurpose, false); assert.equal(result.needsRecipientEmails, false); assert.equal(result.outputPath, outputPath); const stat = await fs.promises.stat(outputPath); diff --git a/skills/property-assessor/tests/report-pdf.test.ts b/skills/property-assessor/tests/report-pdf.test.ts index f9519a9..6d06b8c 100644 --- a/skills/property-assessor/tests/report-pdf.test.ts +++ b/skills/property-assessor/tests/report-pdf.test.ts @@ -8,6 +8,7 @@ import { ReportValidationError, renderReportPdf } from "../src/report-pdf.js"; const samplePayload = { recipientEmails: ["buyer@example.com"], + assessmentPurpose: "investment property", reportTitle: "Property Assessment Report", subjectProperty: { address: "4141 Whiteley Dr, Corpus Christi, TX 78418", diff --git a/skills/web-automation/.gitignore b/skills/web-automation/.gitignore index 3c45938..a24621f 100644 --- a/skills/web-automation/.gitignore +++ b/skills/web-automation/.gitignore @@ -1,3 +1,7 @@ node_modules/ *.log .DS_Store +scripts/page-*.html +scripts/tmp_*.mjs +scripts/expedia_dump.mjs +scripts/pnpm-workspace.yaml diff --git a/skills/web-automation/SKILL.md b/skills/web-automation/SKILL.md index 47878f5..8298713 100644 --- a/skills/web-automation/SKILL.md +++ b/skills/web-automation/SKILL.md @@ -66,6 +66,8 @@ pnpm rebuild better-sqlite3 esbuild ## Quick Reference - Install check: `node check-install.js` +- Zillow listing discovery from address: `node scripts/zillow-discover.js "4141 Whiteley Dr, Corpus Christi, TX 78418"` +- HAR listing discovery from address: `node scripts/har-discover.js "4141 Whiteley Dr, Corpus Christi, TX 78418"` - One-shot JSON extract: `node scripts/extract.js "https://example.com"` - Zillow photo URLs: `node scripts/zillow-photos.js "https://www.zillow.com/homedetails/..."` - HAR photo URLs: `node scripts/har-photos.js "https://www.har.com/homedetail/..."` @@ -134,10 +136,17 @@ npx tsx flow.ts --instruction 'go to https://search.fiorinis.com then type "pipp Use the dedicated extractors before trying a free-form gallery flow. +- Zillow discovery: `node scripts/zillow-discover.js ""` +- HAR discovery: `node scripts/har-discover.js ""` - Zillow: `node scripts/zillow-photos.js ""` - HAR: `node scripts/har-photos.js ""` -These scripts are purpose-built for the common `See all photos` / `Show all photos` workflow: +The discovery scripts are purpose-built for the common address-to-listing workflow: +- open the site search or address URL +- resolve or identify a matching listing page when possible +- return the direct listing URL as JSON + +The photo scripts are purpose-built for the common `See all photos` / `Show all photos` workflow: - open the listing page - click the all-photos entry point - wait for the resulting photo page or scroller view diff --git a/skills/web-automation/scripts/har-discover.js b/skills/web-automation/scripts/har-discover.js new file mode 100644 index 0000000..ad19bc2 --- /dev/null +++ b/skills/web-automation/scripts/har-discover.js @@ -0,0 +1,139 @@ +#!/usr/bin/env node + +import { + createPageSession, + dismissCommonOverlays, + fail, + gotoListing, + sleep, +} from "./real-estate-photo-common.js"; + +function parseAddress(rawAddress) { + const address = String(rawAddress || "").trim(); + if (!address) { + fail("Missing address."); + } + return address; +} + +function buildSearchUrl(address) { + return `https://www.har.com/search/?q=${encodeURIComponent(address)}`; +} + +function buildAddressTokens(address) { + return address + .toLowerCase() + .replace(/[^a-z0-9\s]/g, " ") + .split(/\s+/) + .filter(Boolean) + .filter((token) => !new Set(["tx", "dr", "st", "rd", "ave", "blvd", "ct", "ln", "cir"]).has(token)); +} + +function normalizeListingUrl(url) { + try { + const parsed = new URL(url); + parsed.search = ""; + parsed.hash = ""; + return parsed.toString(); + } catch { + return null; + } +} + +async function collectListingUrl(page) { + return page.evaluate(() => { + const toAbsolute = (href) => { + try { + return new URL(href, location.href).toString(); + } catch { + return null; + } + }; + + const candidates = []; + for (const anchor of document.querySelectorAll('a[href*="/homedetail/"]')) { + const href = anchor.getAttribute("href"); + if (!href) continue; + const absolute = toAbsolute(href); + if (!absolute) continue; + const text = (anchor.textContent || "").replace(/\s+/g, " ").trim(); + const parentText = (anchor.parentElement?.textContent || "").replace(/\s+/g, " ").trim(); + candidates.push({ + url: absolute, + text, + parentText, + }); + } + + const unique = []; + for (const candidate of candidates) { + if (!unique.some((item) => item.url === candidate.url)) unique.push(candidate); + } + return unique; + }); +} + +async function main() { + const address = parseAddress(process.argv[2]); + const searchUrl = buildSearchUrl(address); + const { context, page } = await createPageSession({ headless: process.env.HEADLESS !== "false" }); + + try { + const attempts = [`Opened HAR search URL: ${searchUrl}`]; + await gotoListing(page, searchUrl, 2500); + await dismissCommonOverlays(page); + await sleep(1500); + + let listingUrl = null; + const addressTokens = buildAddressTokens(address); + if (page.url().includes("/homedetail/")) { + listingUrl = normalizeListingUrl(page.url()); + attempts.push("HAR search URL resolved directly to a property page."); + } else { + const discovered = await collectListingUrl(page); + const scored = discovered + .map((candidate) => { + const haystack = `${candidate.url} ${candidate.text} ${candidate.parentText}`.toLowerCase(); + const score = addressTokens.reduce( + (total, token) => total + (haystack.includes(token) ? 1 : 0), + 0 + ); + return { ...candidate, score }; + }) + .sort((a, b) => b.score - a.score); + + if (scored[0] && scored[0].score >= Math.min(3, addressTokens.length)) { + listingUrl = normalizeListingUrl(scored[0].url); + attempts.push(`HAR search results exposed a matching homedetail link with score ${scored[0].score}.`); + } else { + attempts.push("HAR discovery did not expose a confident homedetail match for this address."); + } + } + + process.stdout.write( + `${JSON.stringify( + { + source: "har", + address, + searchUrl, + finalUrl: page.url(), + title: await page.title(), + listingUrl, + attempts, + }, + null, + 2 + )}\n` + ); + await context.close(); + } catch (error) { + try { + await context.close(); + } catch { + // Ignore close errors after the primary failure. + } + fail("HAR discovery failed.", error instanceof Error ? error.message : String(error)); + } +} + +main(); diff --git a/skills/web-automation/scripts/package.json b/skills/web-automation/scripts/package.json index 6f609ce..3cfd15a 100644 --- a/skills/web-automation/scripts/package.json +++ b/skills/web-automation/scripts/package.json @@ -6,10 +6,12 @@ "scripts": { "check-install": "node check-install.js", "extract": "node extract.js", + "har-discover": "node har-discover.js", "har-photos": "node har-photos.js", "browse": "tsx browse.ts", "scrape": "tsx scrape.ts", "test:photos": "node --test real-estate-photo-common.test.mjs zillow-photo-data.test.mjs", + "zillow-discover": "node zillow-discover.js", "zillow-photos": "node zillow-photos.js", "fetch-browser": "npx cloakbrowser install" }, diff --git a/skills/web-automation/scripts/zillow-discover.js b/skills/web-automation/scripts/zillow-discover.js new file mode 100644 index 0000000..b9097be --- /dev/null +++ b/skills/web-automation/scripts/zillow-discover.js @@ -0,0 +1,118 @@ +#!/usr/bin/env node + +import { + createPageSession, + dismissCommonOverlays, + fail, + gotoListing, + sleep, +} from "./real-estate-photo-common.js"; + +function parseAddress(rawAddress) { + const address = String(rawAddress || "").trim(); + if (!address) { + fail("Missing address."); + } + return address; +} + +function buildSearchUrl(address) { + const slug = address + .replace(/,/g, "") + .replace(/#/g, "") + .trim() + .split(/\s+/) + .join("-"); + return `https://www.zillow.com/homes/${encodeURIComponent(slug)}_rb/`; +} + +function normalizeListingUrl(url) { + try { + const parsed = new URL(url); + parsed.search = ""; + parsed.hash = ""; + return parsed.toString(); + } catch { + return null; + } +} + +async function collectListingUrl(page) { + return page.evaluate(() => { + const toAbsolute = (href) => { + try { + return new URL(href, location.href).toString(); + } catch { + return null; + } + }; + + const candidates = []; + for (const anchor of document.querySelectorAll('a[href*="/homedetails/"]')) { + const href = anchor.getAttribute("href"); + if (!href) continue; + const absolute = toAbsolute(href); + if (!absolute) continue; + candidates.push(absolute); + } + + const unique = []; + for (const candidate of candidates) { + if (!unique.includes(candidate)) unique.push(candidate); + } + return unique[0] || null; + }); +} + +async function main() { + const address = parseAddress(process.argv[2]); + const searchUrl = buildSearchUrl(address); + const { context, page } = await createPageSession({ headless: process.env.HEADLESS !== "false" }); + + try { + const attempts = [`Opened Zillow address search URL: ${searchUrl}`]; + await gotoListing(page, searchUrl, 2500); + await dismissCommonOverlays(page); + await sleep(1500); + + let listingUrl = null; + if (page.url().includes("/homedetails/")) { + listingUrl = normalizeListingUrl(page.url()); + attempts.push("Zillow search URL resolved directly to a property page."); + } else { + const discovered = await collectListingUrl(page); + if (discovered) { + listingUrl = normalizeListingUrl(discovered); + attempts.push("Zillow search results exposed a homedetails link."); + } else { + attempts.push("Zillow discovery did not expose a homedetails link for this address."); + } + } + + process.stdout.write( + `${JSON.stringify( + { + source: "zillow", + address, + searchUrl, + finalUrl: page.url(), + title: await page.title(), + listingUrl, + attempts, + }, + null, + 2 + )}\n` + ); + await context.close(); + } catch (error) { + try { + await context.close(); + } catch { + // Ignore close errors after the primary failure. + } + fail("Zillow discovery failed.", error instanceof Error ? error.message : String(error)); + } +} + +main();