Add purpose-aware property assessor intake
This commit is contained in:
@@ -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<string, unknown>;
|
||||
}> {
|
||||
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<string, unknown>
|
||||
): 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<AssessPropertyResult> {
|
||||
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,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ReportValidationError, loadReportPayload, renderReportPdf } from "./rep
|
||||
function usage(): void {
|
||||
process.stdout.write(`property-assessor\n
|
||||
Commands:
|
||||
assess --address "<address>" [--recipient-email "<email>"] [--output "<report.pdf>"] [--parcel-id "<id>"] [--listing-geo-id "<id>"] [--listing-source-url "<url>"]
|
||||
assess --address "<address>" --assessment-purpose "<purpose>" [--recipient-email "<email>"] [--output "<report.pdf>"] [--parcel-id "<id>"] [--listing-geo-id "<id>"] [--listing-source-url "<url>"]
|
||||
locate-public-records --address "<address>" [--parcel-id "<id>"] [--listing-geo-id "<id>"] [--listing-source-url "<url>"]
|
||||
render-report --input "<payload.json>" --output "<report.pdf>"
|
||||
`);
|
||||
@@ -17,7 +17,16 @@ Commands:
|
||||
|
||||
async function main(): Promise<void> {
|
||||
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<void> {
|
||||
}
|
||||
const payload = await assessProperty({
|
||||
address: argv.address,
|
||||
assessmentPurpose: argv["assessment-purpose"],
|
||||
recipientEmails: argv["recipient-email"],
|
||||
output: argv.output,
|
||||
parcelId: argv["parcel-id"],
|
||||
|
||||
66
skills/property-assessor/src/listing-discovery.ts
Normal file
66
skills/property-assessor/src/listing-discovery.ts
Normal file
@@ -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<ListingDiscoveryResult> {
|
||||
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
|
||||
};
|
||||
}
|
||||
56
skills/property-assessor/src/photo-review.ts
Normal file
56
skills/property-assessor/src/photo-review.ts
Normal file
@@ -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<string, unknown>;
|
||||
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<PhotoExtractionResult> {
|
||||
const scriptMap: Record<PhotoSource, string> = {
|
||||
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) : []
|
||||
};
|
||||
}
|
||||
@@ -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"}`],
|
||||
|
||||
Reference in New Issue
Block a user