Port property assessor helpers to TypeScript
This commit is contained in:
@@ -1,38 +1,44 @@
|
||||
# property-assessor
|
||||
|
||||
Decision-grade residential property assessment skill for OpenClaw.
|
||||
|
||||
This skill is for evaluating a condo, townhouse, house, or similar residential property from an address or listing URL and ending with a practical recommendation such as `buy`, `pass`, or `only below X`.
|
||||
Decision-grade residential property assessment skill for OpenClaw, with official public-record enrichment and fixed-template PDF report rendering.
|
||||
|
||||
## Overview
|
||||
|
||||
`property-assessor` is a workflow skill, not just a scraper. It is meant to:
|
||||
`property-assessor` is for evaluating a condo, townhouse, house, or similar residential property from an address or listing URL and ending with a practical recommendation such as `buy`, `pass`, or `only below X`.
|
||||
|
||||
- normalize the target property across listing sources
|
||||
- build a baseline fact set
|
||||
The skill is intended to:
|
||||
|
||||
- normalize the property across listing sources
|
||||
- review listing photos before making condition claims
|
||||
- compare the property against nearby or same-building comps
|
||||
- underwrite taxes, HOA, insurance, and realistic carrying costs
|
||||
- identify risk drivers
|
||||
- produce a concise but decision-grade verdict
|
||||
- incorporate official public-record / appraisal-district context when available
|
||||
- compare the property against comps and carrying costs
|
||||
- produce a fixed-format PDF report, not just ad hoc chat prose
|
||||
|
||||
The skill is designed for real-world purchase decisions, especially when you need a fast read on whether a property is worth pursuing.
|
||||
## Standalone helper usage
|
||||
|
||||
## Accepted inputs
|
||||
This skill now ships with a small TypeScript helper package for two tasks:
|
||||
|
||||
The skill can start from any of:
|
||||
- locating official public-record jurisdiction from an address
|
||||
- rendering a fixed-template PDF report
|
||||
|
||||
- a street address
|
||||
- a Zillow listing URL
|
||||
- a HAR listing URL
|
||||
- an address plus user constraints such as:
|
||||
- investment only
|
||||
- owner-occupant
|
||||
- long-term rental
|
||||
- short-term rental
|
||||
- target distance/location requirements
|
||||
From `skills/property-assessor/`:
|
||||
|
||||
Preferred starting point is the address when available, because it makes source reconciliation easier.
|
||||
```bash
|
||||
npm install
|
||||
scripts/property-assessor --help
|
||||
```
|
||||
|
||||
The wrapper script uses the skill-local Node dependencies under `node_modules/`.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
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"
|
||||
scripts/property-assessor locate-public-records --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --listing-geo-id "233290"
|
||||
|
||||
scripts/property-assessor render-report --input examples/report-payload.example.json --output /tmp/property-assessment.pdf
|
||||
```
|
||||
|
||||
## Core workflow
|
||||
|
||||
@@ -40,16 +46,15 @@ Default operating sequence:
|
||||
|
||||
1. Normalize the address and property type.
|
||||
2. Discover accessible listing and public-record sources for the same property.
|
||||
3. Establish a baseline fact set from the best available source.
|
||||
4. Cross-check the same property on other sources.
|
||||
5. Review listing photos before making condition claims.
|
||||
6. Pull same-building comps for condos or nearby comps for houses/townhomes.
|
||||
7. Underwrite carry costs and risk drivers.
|
||||
8. End with a specific recommendation and fair-value range.
|
||||
3. Build a baseline fact set.
|
||||
4. Review listing photos before making condition claims.
|
||||
5. Pull same-building or nearby comps.
|
||||
6. Underwrite carry costs and risk factors.
|
||||
7. Render the final report as a fixed-template PDF.
|
||||
|
||||
## Source priority
|
||||
|
||||
Unless the user asks otherwise, preferred source order is:
|
||||
Unless the user says otherwise, preferred listing/source order is:
|
||||
|
||||
1. Zillow
|
||||
2. Redfin
|
||||
@@ -57,11 +62,72 @@ Unless the user asks otherwise, preferred source order is:
|
||||
4. HAR / Homes.com / brokerage mirrors
|
||||
5. county or appraisal pages
|
||||
|
||||
Use high-quality mirrors to confirm facts, not to override a clearly better primary listing without reason.
|
||||
Public-record / assessor data should be linked in the final result when available.
|
||||
|
||||
## Public-record enrichment
|
||||
|
||||
The skill should not rely on listing-site geo IDs as if they were assessor record identifiers.
|
||||
|
||||
Correct approach:
|
||||
|
||||
1. start from the street address
|
||||
2. resolve the address to state/county/FIPS/GEOID
|
||||
3. identify the official public-record jurisdiction
|
||||
4. use parcel/APN/account identifiers when available
|
||||
5. link the official jurisdiction page and any direct property page used
|
||||
|
||||
### `locate-public-records`
|
||||
|
||||
```bash
|
||||
scripts/property-assessor locate-public-records --address "<street-address>"
|
||||
```
|
||||
|
||||
Current behavior:
|
||||
|
||||
- uses the official Census geocoder
|
||||
- returns matched address, county/state/FIPS, and block GEOID context
|
||||
- for Texas, returns:
|
||||
- Texas Comptroller county directory page
|
||||
- appraisal district contact/site details
|
||||
- tax assessor/collector contact/site details
|
||||
|
||||
Important rules:
|
||||
|
||||
- Zillow/Redfin/HAR geo IDs are hints only
|
||||
- parcel/APN/account IDs are stronger search keys than listing geo IDs
|
||||
- official jurisdiction pages should be linked in the final report
|
||||
- if a direct property detail page is accessible, its data should be labeled as official public-record evidence
|
||||
|
||||
### Texas support
|
||||
|
||||
Texas is the first-class public-record path in this implementation.
|
||||
|
||||
For Texas addresses, the helper resolves:
|
||||
|
||||
- the official Census geocoder link
|
||||
- the official Texas Comptroller county directory page
|
||||
- the appraisal district website
|
||||
- the tax assessor/collector website
|
||||
|
||||
That output should be used by the skill to:
|
||||
|
||||
- identify the correct CAD
|
||||
- attempt address / parcel / account lookup on the CAD site
|
||||
- capture official assessed values and exemptions when a public detail page is available
|
||||
|
||||
Recommended fields to capture from official records when accessible:
|
||||
|
||||
- account number
|
||||
- owner name
|
||||
- land value
|
||||
- improvement value
|
||||
- assessed total
|
||||
- exemptions
|
||||
- official property-detail URL
|
||||
|
||||
## Minimum data to capture
|
||||
|
||||
For the target property, capture as many of these as the sources support:
|
||||
For the subject property, capture when available:
|
||||
|
||||
- address
|
||||
- list price or last known list price
|
||||
@@ -79,169 +145,107 @@ For the target property, capture as many of these as the sources support:
|
||||
- subdivision or building name
|
||||
- same-building or nearby active inventory
|
||||
- listing photos and visible condition cues
|
||||
- included appliances and obvious missing appliances
|
||||
- flooring mix, especially carpet
|
||||
- public-record jurisdiction and linked official source
|
||||
- account / parcel / tax ID if confirmed
|
||||
- official assessed values and exemptions if confirmed
|
||||
|
||||
## Photo-review requirement
|
||||
## Photo-review rules
|
||||
|
||||
Photo review is mandatory when the listing sources expose photos.
|
||||
Photo review is mandatory when photos are exposed by a listing source.
|
||||
|
||||
Do not make strong condition claims from structured text alone if photos are available.
|
||||
|
||||
### What counts as acceptable photo access
|
||||
|
||||
Preferred photo sources are:
|
||||
|
||||
- a scrollable all-photos page
|
||||
- an expanded photo grid
|
||||
- a photo page that exposes the full set
|
||||
- a modal/lightbox only if the site does not provide a better all-photos path
|
||||
|
||||
Do not treat these as full photo review by themselves:
|
||||
|
||||
- the listing hero image
|
||||
- a collage preview
|
||||
- a photo count without image access
|
||||
- listing shell text that mentions photos
|
||||
|
||||
### What to inspect in the photos
|
||||
|
||||
At minimum, evaluate:
|
||||
|
||||
- overall finish level: dated, average, lightly updated, fully updated
|
||||
- kitchen condition: cabinets, counters, backsplash, appliances
|
||||
- bathroom condition: vanity, tile, surrounds, fixtures
|
||||
- flooring: tile, vinyl, laminate, hardwood, carpet
|
||||
- obvious make-ready issues: paint, trim, wear, damage, mismatched finishes
|
||||
- visible missing items: refrigerator, washer/dryer, range hood, dishwasher
|
||||
- signs of deferred maintenance or water intrusion
|
||||
- exterior and common-area condition where visible
|
||||
- waterfront-facing elements, balconies, decks, sliders, and windows when relevant
|
||||
|
||||
### If photo review is incomplete
|
||||
|
||||
If the agent cannot access enough photos to make a credible read:
|
||||
|
||||
- say so explicitly
|
||||
- lower confidence
|
||||
- avoid strong turnkey claims
|
||||
- continue the broader underwriting work, but mark condition as limited-confidence
|
||||
|
||||
## Zillow and HAR integration
|
||||
|
||||
This skill now expects the dedicated `web-automation` extractors first instead of fragile ad hoc gallery automation.
|
||||
|
||||
### Zillow first
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/web-automation/scripts
|
||||
node zillow-photos.js "<zillow-listing-url>"
|
||||
```
|
||||
|
||||
Successful Zillow photo access means one of these happened:
|
||||
|
||||
- the `See all photos` / `See all X photos` path opened a usable all-photos experience, or
|
||||
- the rendered Zillow listing shell already exposed the full direct Zillow image set and the extracted count matches the announced count
|
||||
|
||||
Important rule:
|
||||
- when the extractor returns `imageUrls`, that returned set is the photo-review set
|
||||
|
||||
For smaller listings, review the full extracted set when practical. For a 20-30 photo listing, that usually means all photos.
|
||||
|
||||
### HAR fallback
|
||||
|
||||
If Zillow does not expose a reliable image set, use HAR next:
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/web-automation/scripts
|
||||
node har-photos.js "<har-listing-url>"
|
||||
```
|
||||
|
||||
Successful HAR photo access means:
|
||||
|
||||
- the HAR listing opened
|
||||
- `Show all photos` / `View all photos` exposed the photo page
|
||||
- direct `pics.harstatic.com` image URLs were extracted
|
||||
|
||||
As with Zillow, the returned `imageUrls` are the review set for condition analysis.
|
||||
|
||||
### Practical photo-source order
|
||||
|
||||
Use this action order:
|
||||
Preferred photo-access order:
|
||||
|
||||
1. Zillow extractor
|
||||
2. HAR extractor
|
||||
3. Realtor.com photo page
|
||||
4. brokerage mirror or other accessible listing mirror
|
||||
|
||||
Do not stop after the first failed source if a fallback source can still expose the photos.
|
||||
Use the dedicated `web-automation` extractors first:
|
||||
|
||||
## Approval-safe execution
|
||||
|
||||
For chat-driven property assessments, prefer file-based commands under:
|
||||
|
||||
```text
|
||||
~/.openclaw/workspace/skills/web-automation/scripts
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/web-automation/scripts
|
||||
node zillow-photos.js "<zillow-listing-url>"
|
||||
node har-photos.js "<har-listing-url>"
|
||||
```
|
||||
|
||||
Good command shape:
|
||||
When those extractors return `imageUrls`, that returned set is the photo-review set.
|
||||
|
||||
## Approval-safe command shape
|
||||
|
||||
For chat-driven runs, prefer file-based commands.
|
||||
|
||||
Good:
|
||||
|
||||
- `node check-install.js`
|
||||
- `node zillow-photos.js "<url>"`
|
||||
- `node har-photos.js "<url>"`
|
||||
- `scripts/property-assessor locate-public-records --address "..."`
|
||||
- `scripts/property-assessor render-report --input ... --output ...`
|
||||
|
||||
Avoid when possible:
|
||||
|
||||
- `node -e "..."`
|
||||
- `node --input-type=module -e "..."`
|
||||
|
||||
Why this matters:
|
||||
## PDF report template
|
||||
|
||||
- OpenClaw exec approvals are easier to allowlist for stable file paths
|
||||
- inline interpreter eval is more likely to trigger approval friction
|
||||
- the installed approval allowlist is typically already scoped to the `*.js` files under the `web-automation/scripts` directory
|
||||
The final deliverable should be a fixed-template PDF, not a one-off layout.
|
||||
|
||||
## Make-ready normalization
|
||||
Template reference:
|
||||
|
||||
Condition should be translated into a rough make-ready range so pricing and comp comparisons stay realistic.
|
||||
- `skills/property-assessor/references/report-template.md`
|
||||
|
||||
Use simple buckets:
|
||||
Current renderer:
|
||||
|
||||
- light make-ready
|
||||
- paint, fixtures, minor hardware, small patching
|
||||
- medium make-ready
|
||||
- partial flooring replacement, appliance replacement, bathroom refresh
|
||||
- heavy make-ready
|
||||
- significant kitchen/bath work, widespread flooring, visible deferred maintenance
|
||||
```bash
|
||||
scripts/property-assessor render-report --input "<report-payload-json>" --output "<output-pdf>"
|
||||
```
|
||||
|
||||
Call out carpet separately when present, especially in bedrooms, stairs, or living areas.
|
||||
The fixed template includes:
|
||||
|
||||
## Underwriting expectations
|
||||
1. Report header
|
||||
2. Verdict panel
|
||||
3. Subject-property summary table
|
||||
4. Snapshot
|
||||
5. What I Like
|
||||
6. What I Do Not Like
|
||||
7. Comp View
|
||||
8. Underwriting / Carry View
|
||||
9. Risks and Diligence Items
|
||||
10. Photo Review
|
||||
11. Public Records
|
||||
12. Source Links
|
||||
13. Notes page
|
||||
|
||||
The final assessment should show a simple carrying-cost view including:
|
||||
### Recipient email gate
|
||||
|
||||
- principal and interest if available
|
||||
- taxes per month
|
||||
- HOA per month if applicable
|
||||
- insurance estimate or explicit uncertainty
|
||||
- realistic carry range after maintenance, vacancy, and property-specific risk
|
||||
The report must not be rendered or sent unless target recipient email address(es) are known.
|
||||
|
||||
Strong caution flags include:
|
||||
If the prompt does not include recipient email(s), the skill should:
|
||||
|
||||
- high HOA relative to price or expected rent
|
||||
- older waterfront or coastal exposure
|
||||
- unknown reserve or assessment history for condos
|
||||
- many active units in the same building or micro-area
|
||||
- stale days on market with weak price action
|
||||
- no clear rent support
|
||||
- stop
|
||||
- ask for target recipient email address(es)
|
||||
- not finalize the PDF workflow yet
|
||||
|
||||
The renderer enforces this. If `recipientEmails` is missing or empty, it fails with:
|
||||
|
||||
`Missing target email. Stop and ask the user for target email address(es) before generating or sending the property assessment PDF.`
|
||||
|
||||
## Example payload
|
||||
|
||||
Sample payload:
|
||||
|
||||
- `skills/property-assessor/examples/report-payload.example.json`
|
||||
|
||||
This is the easiest way to test the renderer without building a report payload from scratch.
|
||||
|
||||
## Output contract
|
||||
|
||||
The final answer should stay concise but decision-grade.
|
||||
The assessment itself should remain concise but decision-grade.
|
||||
|
||||
Recommended structure:
|
||||
Recommended narrative structure:
|
||||
|
||||
1. Snapshot
|
||||
2. What I like
|
||||
@@ -251,76 +255,77 @@ Recommended structure:
|
||||
6. Risks and diligence items
|
||||
7. Verdict with fair-value range and offer guidance
|
||||
|
||||
The output must explicitly include:
|
||||
It must also explicitly include:
|
||||
|
||||
- `Photo source attempts: ...`
|
||||
- `Photo review: completed via <source>`
|
||||
or `Photo review: not completed`
|
||||
- `Photo review: completed via <source>` or `Photo review: not completed`
|
||||
- public-record / CAD evidence and links when available
|
||||
|
||||
If photo review was completed:
|
||||
## Validation flow
|
||||
|
||||
- summarize the condition read from the photos
|
||||
- mention obvious finish level, flooring, appliance presence, and make-ready signals
|
||||
|
||||
If not completed:
|
||||
|
||||
- mark condition confidence as limited
|
||||
- explain why photo access was incomplete
|
||||
|
||||
## Example validation flow
|
||||
|
||||
Use these commands for a known-good regression check.
|
||||
|
||||
### Verify extractor prerequisites
|
||||
### 1. Install the helper package locally
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/web-automation/scripts
|
||||
node check-install.js
|
||||
npm run test:photos
|
||||
cd ~/.openclaw/workspace/skills/property-assessor
|
||||
npm install
|
||||
npm test
|
||||
```
|
||||
|
||||
### Zillow regression check
|
||||
### 2. Run public-record lookup
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/web-automation/scripts
|
||||
node zillow-photos.js "https://www.zillow.com/homedetails/4141-Whiteley-Dr-Corpus-Christi-TX-78418/2103723704_zpid/"
|
||||
cd ~/.openclaw/workspace/skills/property-assessor
|
||||
scripts/property-assessor locate-public-records --address "4141 Whiteley Dr, Corpus Christi, TX 78418"
|
||||
```
|
||||
|
||||
Expected shape:
|
||||
|
||||
- `complete: true`
|
||||
- `expectedPhotoCount: 29`
|
||||
- `photoCount: 29`
|
||||
- state/county/FIPS/GEOID present
|
||||
- official Census geocoder link present
|
||||
- for Texas: Comptroller county directory link present
|
||||
- for Texas: appraisal district and tax assessor/collector contacts present
|
||||
|
||||
### HAR regression check
|
||||
### 3. Run PDF render with the sample payload
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/web-automation/scripts
|
||||
node har-photos.js "https://www.har.com/homedetail/4141-whiteley-dr-corpus-christi-tx-78418/14069438"
|
||||
cd ~/.openclaw/workspace/skills/property-assessor
|
||||
scripts/property-assessor render-report --input examples/report-payload.example.json --output /tmp/property-assessment.pdf
|
||||
```
|
||||
|
||||
Expected shape:
|
||||
Expected result:
|
||||
|
||||
- `complete: true`
|
||||
- `expectedPhotoCount: 29`
|
||||
- `photoCount: 29`
|
||||
- JSON success payload with `outputPath`
|
||||
- a non-empty PDF written to `/tmp/property-assessment.pdf`
|
||||
|
||||
### Skill-level validation
|
||||
### 4. Verify the email gate
|
||||
|
||||
When testing `property-assessor` itself, confirm the resulting assessment:
|
||||
Run the renderer with a payload that omits `recipientEmails`.
|
||||
|
||||
- attempts Zillow first
|
||||
- falls back to HAR if needed
|
||||
- references actual photo access, not just listing text
|
||||
- includes the required `Photo source attempts` line
|
||||
- includes the required `Photo review` line
|
||||
- makes condition claims consistent with the reviewed image set
|
||||
Expected result:
|
||||
|
||||
- non-zero exit
|
||||
- explicit message telling the operator to stop and ask for target recipient email(s)
|
||||
|
||||
### 5. Verify the end-to-end skill behavior
|
||||
|
||||
When testing `property-assessor` itself, confirm the assessment:
|
||||
|
||||
- starts from the address when available
|
||||
- uses Zillow first for photo extraction, HAR as fallback
|
||||
- uses official public-record jurisdiction links when available
|
||||
- does not treat listing geo IDs as assessor keys
|
||||
- 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
|
||||
|
||||
## Related files
|
||||
|
||||
- installed skill instructions:
|
||||
- `~/.openclaw/workspace/skills/property-assessor/SKILL.md`
|
||||
- repo skill instructions:
|
||||
- skill instructions:
|
||||
- `skills/property-assessor/SKILL.md`
|
||||
- photo extractor docs:
|
||||
- underwriting heuristics:
|
||||
- `skills/property-assessor/references/underwriting-rules.md`
|
||||
- PDF template rules:
|
||||
- `skills/property-assessor/references/report-template.md`
|
||||
- sample report payload:
|
||||
- `skills/property-assessor/examples/report-payload.example.json`
|
||||
- photo extraction docs:
|
||||
- `docs/web-automation.md`
|
||||
|
||||
3
skills/property-assessor/.gitignore
vendored
Normal file
3
skills/property-assessor/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.venv/
|
||||
@@ -53,6 +53,69 @@ Prefer this order unless the user says otherwise:
|
||||
Use the `web-automation` skill for rendered pages and anti-bot-heavy sites.
|
||||
Use `web_search` sparingly to discover alternate URLs, then return to `web-automation` for extraction.
|
||||
|
||||
## Helper runtime
|
||||
|
||||
`property-assessor` now includes TypeScript helper commands for:
|
||||
- public-record jurisdiction lookup
|
||||
- fixed-template PDF rendering
|
||||
|
||||
Before using those helper commands:
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/property-assessor
|
||||
npm install
|
||||
```
|
||||
|
||||
## Public-record enrichment
|
||||
|
||||
Public-record / assessor data should be used when available and linked in the final result.
|
||||
|
||||
Default approach:
|
||||
1. start from the street address
|
||||
2. resolve the address to county/state/geography
|
||||
3. identify the related appraisal district / assessor jurisdiction
|
||||
4. use the official public-record site as a primary-source check against listing data
|
||||
5. link the official jurisdiction page and any direct property page used in the final result
|
||||
|
||||
Use the helper CLI first:
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/property-assessor
|
||||
npm install
|
||||
scripts/property-assessor locate-public-records --address "<street-address>"
|
||||
```
|
||||
|
||||
This command currently:
|
||||
- resolves the address through the official Census geocoder
|
||||
- returns county/state/FIPS/GEOID context
|
||||
- for Texas, resolves the official Texas Comptroller county directory page
|
||||
- returns the county appraisal district and tax assessor/collector links when available
|
||||
|
||||
Important rules:
|
||||
- listing-site geo IDs are hints only; do **not** treat them as assessor record keys
|
||||
- parcel/APN/account identifiers from Zillow/HAR/Redfin are much stronger keys than listing geo IDs
|
||||
- if a direct public-record property page is available, use its data in the assessment and link it explicitly
|
||||
- if the jurisdiction can be identified but the property detail page is not directly retrievable, still link the official jurisdiction page and say what could not be confirmed
|
||||
|
||||
### Texas rule
|
||||
|
||||
For Texas properties, public-record enrichment is required when feasible.
|
||||
|
||||
Process:
|
||||
1. run `locate-public-records` from the subject address
|
||||
2. use the returned Texas Comptroller county directory page as the official jurisdiction reference
|
||||
3. use the returned CAD website for address / account / parcel lookup
|
||||
4. when accessible, capture:
|
||||
- account number
|
||||
- owner name
|
||||
- land value
|
||||
- improvement value
|
||||
- assessed total
|
||||
- exemptions
|
||||
- tax office links
|
||||
|
||||
In the final assessment, explicitly label official public-record facts as such.
|
||||
|
||||
## Minimum data to capture
|
||||
|
||||
For the target property, capture when available:
|
||||
@@ -74,6 +137,9 @@ For the target property, capture when available:
|
||||
- listing photos and visible condition cues
|
||||
- included appliances and obvious missing appliances
|
||||
- flooring mix, especially whether carpet is present
|
||||
- public-record jurisdiction and linked official source
|
||||
- account / parcel / tax ID if confirmed
|
||||
- official assessed values and exemptions if confirmed
|
||||
|
||||
## Photo and condition review
|
||||
|
||||
@@ -217,6 +283,51 @@ The final assessment must explicitly include these lines in the output:
|
||||
If completed, briefly summarize the condition read from the photos.
|
||||
If not completed, mark condition confidence as limited and say why.
|
||||
|
||||
## PDF report requirement
|
||||
|
||||
The deliverable is not just chat text. A fixed-template PDF report must be generated for completed assessments.
|
||||
|
||||
Use the property-assessor helper CLI:
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/property-assessor
|
||||
npm install
|
||||
scripts/property-assessor render-report --input "<report-payload-json>" --output "<output-pdf>"
|
||||
```
|
||||
|
||||
The renderer uses a fixed template and must keep the same look across runs.
|
||||
Template rules are documented in `references/report-template.md`.
|
||||
|
||||
The PDF report should include:
|
||||
1. report header
|
||||
2. verdict panel
|
||||
3. subject-property summary table
|
||||
4. Snapshot
|
||||
5. What I like
|
||||
6. What I do not like
|
||||
7. Comp view
|
||||
8. Underwriting / carry view
|
||||
9. Risks and diligence items
|
||||
10. Photo review
|
||||
11. Public records
|
||||
12. Source links
|
||||
|
||||
### Recipient-email gate
|
||||
|
||||
Before rendering or sending the PDF, the skill must know the target recipient email address(es).
|
||||
|
||||
If the prompt does **not** include target email(s):
|
||||
- stop
|
||||
- ask the user for the target email address(es)
|
||||
- do **not** render or send the final PDF yet
|
||||
|
||||
If target email(s) are present:
|
||||
- include them in the report payload
|
||||
- render the PDF with the fixed template
|
||||
- if a delivery workflow is available, use those same target email(s) for sending
|
||||
|
||||
The renderer enforces this gate and will fail if the payload has no recipient email list.
|
||||
|
||||
## Normalization / make-ready adjustment
|
||||
|
||||
Estimate a rough make-ready budget when condition is not turnkey. The goal is not contractor precision; the goal is apples-to-apples comparison.
|
||||
@@ -257,7 +368,15 @@ Keep the answer concise but decision-grade:
|
||||
6. Risks and diligence items
|
||||
7. Verdict with fair value range and offer guidance
|
||||
|
||||
Also include:
|
||||
- public-record / CAD evidence and links when available
|
||||
- the path to the rendered PDF after generation
|
||||
|
||||
If the user did not provide recipient email(s), ask for them instead of finalizing the PDF workflow.
|
||||
|
||||
## Reuse notes
|
||||
|
||||
When condos are involved, same-building comps and HOA economics usually matter more than neighborhood averages.
|
||||
For detailed heuristics and the reusable memo template, read `references/underwriting-rules.md`.
|
||||
For detailed heuristics and the reusable memo template, read:
|
||||
- `references/underwriting-rules.md`
|
||||
- `references/report-template.md`
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"recipientEmails": [
|
||||
"buyer@example.com"
|
||||
],
|
||||
"reportTitle": "Property Assessment Report",
|
||||
"subtitle": "Sample property assessment payload",
|
||||
"subjectProperty": {
|
||||
"address": "4141 Whiteley Dr, Corpus Christi, TX 78418",
|
||||
"listingPrice": 149900,
|
||||
"propertyType": "Townhouse",
|
||||
"beds": 2,
|
||||
"baths": 2,
|
||||
"squareFeet": 900,
|
||||
"yearBuilt": 1978
|
||||
},
|
||||
"verdict": {
|
||||
"decision": "only below x",
|
||||
"fairValueRange": "$132,000 - $138,000",
|
||||
"offerGuidance": "Only attractive below the current ask once HOA, insurance, and make-ready are priced in."
|
||||
},
|
||||
"snapshot": [
|
||||
"Small coastal townhouse with a tight margin at the current ask.",
|
||||
"Needs CAD/public-record reconciliation before a high-confidence offer."
|
||||
],
|
||||
"whatILike": [
|
||||
"Usable 2 bed / 2 bath layout.",
|
||||
"Straightforward official public-record jurisdiction in Nueces County."
|
||||
],
|
||||
"whatIDontLike": [
|
||||
"Ask looks full for the visible finish level.",
|
||||
"Coastal exposure increases long-run carry and maintenance risk."
|
||||
],
|
||||
"compView": [
|
||||
"Need same-building or very local townhome comps before treating ask as fair value."
|
||||
],
|
||||
"carryView": [
|
||||
"Underwrite taxes, HOA, wind/flood insurance, and maintenance together."
|
||||
],
|
||||
"risksAndDiligence": [
|
||||
"Confirm reserve strength and special assessment history.",
|
||||
"Confirm insurance obligations and any storm-related repair history."
|
||||
],
|
||||
"photoReview": {
|
||||
"status": "completed",
|
||||
"source": "Zillow",
|
||||
"attempts": [
|
||||
"Zillow extractor returned the full direct photo set."
|
||||
],
|
||||
"summary": "Interior reads dated-to-average rather than turnkey."
|
||||
},
|
||||
"publicRecords": {
|
||||
"jurisdiction": "Nueces Appraisal District",
|
||||
"accountNumber": "sample-account",
|
||||
"landValue": 42000,
|
||||
"improvementValue": 99000,
|
||||
"assessedTotalValue": 141000,
|
||||
"exemptions": "Not confirmed in sample payload",
|
||||
"links": [
|
||||
{
|
||||
"label": "Texas Comptroller County Directory",
|
||||
"url": "https://comptroller.texas.gov/taxes/property-tax/county-directory/nueces.php"
|
||||
},
|
||||
{
|
||||
"label": "Nueces CAD",
|
||||
"url": "http://www.ncadistrict.com/"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sourceLinks": [
|
||||
{
|
||||
"label": "Zillow Listing",
|
||||
"url": "https://www.zillow.com/homedetails/4141-Whiteley-Dr-Corpus-Christi-TX-78418/2103723704_zpid/"
|
||||
},
|
||||
{
|
||||
"label": "HAR Listing",
|
||||
"url": "https://www.har.com/homedetail/4141-whiteley-dr-corpus-christi-tx-78418/14069438"
|
||||
}
|
||||
]
|
||||
}
|
||||
791
skills/property-assessor/package-lock.json
generated
Normal file
791
skills/property-assessor/package-lock.json
generated
Normal file
@@ -0,0 +1,791 @@
|
||||
{
|
||||
"name": "property-assessor-scripts",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "property-assessor-scripts",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.8",
|
||||
"pdfkit": "^0.17.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/minimist": "^1.2.5",
|
||||
"@types/pdfkit": "^0.17.3",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.20",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.20.tgz",
|
||||
"integrity": "sha512-2egEBHUMasdypIzrprsu8g+OEVd7Vp2MM3a2eVlM/cyFYto0nGz5BX5BTgh/ShZZI9ed+ozEq+Ngt+rgmUs8tw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/minimist": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz",
|
||||
"integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pdfkit": {
|
||||
"version": "0.17.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.5.tgz",
|
||||
"integrity": "sha512-T3ZHnvF91HsEco5ClhBCOuBwobZfPcI2jaiSHybkkKYq4KhVIIurod94JVKvDIG0JXT6o3KiERC0X0//m8dyrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brotli": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-js": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dfa": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.4",
|
||||
"@esbuild/android-arm": "0.27.4",
|
||||
"@esbuild/android-arm64": "0.27.4",
|
||||
"@esbuild/android-x64": "0.27.4",
|
||||
"@esbuild/darwin-arm64": "0.27.4",
|
||||
"@esbuild/darwin-x64": "0.27.4",
|
||||
"@esbuild/freebsd-arm64": "0.27.4",
|
||||
"@esbuild/freebsd-x64": "0.27.4",
|
||||
"@esbuild/linux-arm": "0.27.4",
|
||||
"@esbuild/linux-arm64": "0.27.4",
|
||||
"@esbuild/linux-ia32": "0.27.4",
|
||||
"@esbuild/linux-loong64": "0.27.4",
|
||||
"@esbuild/linux-mips64el": "0.27.4",
|
||||
"@esbuild/linux-ppc64": "0.27.4",
|
||||
"@esbuild/linux-riscv64": "0.27.4",
|
||||
"@esbuild/linux-s390x": "0.27.4",
|
||||
"@esbuild/linux-x64": "0.27.4",
|
||||
"@esbuild/netbsd-arm64": "0.27.4",
|
||||
"@esbuild/netbsd-x64": "0.27.4",
|
||||
"@esbuild/openbsd-arm64": "0.27.4",
|
||||
"@esbuild/openbsd-x64": "0.27.4",
|
||||
"@esbuild/openharmony-arm64": "0.27.4",
|
||||
"@esbuild/sunos-x64": "0.27.4",
|
||||
"@esbuild/win32-arm64": "0.27.4",
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fontkit": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.12",
|
||||
"brotli": "^1.3.2",
|
||||
"clone": "^2.1.2",
|
||||
"dfa": "^1.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"restructure": "^3.0.0",
|
||||
"tiny-inflate": "^1.0.3",
|
||||
"unicode-properties": "^1.4.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.7",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
|
||||
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jpeg-exif": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
|
||||
"integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/linebreak": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "0.0.8",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak/node_modules/base64-js": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdfkit": {
|
||||
"version": "0.17.2",
|
||||
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",
|
||||
"integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"crypto-js": "^4.2.0",
|
||||
"fontkit": "^2.0.4",
|
||||
"jpeg-exif": "^1.1.4",
|
||||
"linebreak": "^1.1.0",
|
||||
"png-js": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/png-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz",
|
||||
"integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/restructure": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-properties": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^0.2.5",
|
||||
"tiny-inflate": "^1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
skills/property-assessor/package.json
Normal file
21
skills/property-assessor/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "property-assessor-scripts",
|
||||
"version": "1.0.0",
|
||||
"description": "Property assessor helpers for public-record lookup and fixed-template PDF rendering",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"locate-public-records": "tsx src/cli.ts locate-public-records",
|
||||
"render-report": "tsx src/cli.ts render-report",
|
||||
"test": "node --import tsx --test tests/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.8",
|
||||
"pdfkit": "^0.17.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/minimist": "^1.2.5",
|
||||
"@types/pdfkit": "^0.17.3",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
56
skills/property-assessor/references/report-template.md
Normal file
56
skills/property-assessor/references/report-template.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Property Assessment PDF Template
|
||||
|
||||
The `property-assessor` PDF output must use the same visual template every time.
|
||||
|
||||
## Template sections
|
||||
|
||||
1. Report header
|
||||
- title: `Property Assessment Report`
|
||||
- subtitle / run context
|
||||
- prepared-for recipient email list
|
||||
- generated timestamp
|
||||
|
||||
2. Verdict panel
|
||||
- decision badge: `BUY`, `PASS`, or `ONLY BELOW X`
|
||||
- offer guidance sentence
|
||||
|
||||
3. Summary table
|
||||
- address
|
||||
- ask / last price
|
||||
- property type
|
||||
- beds / baths
|
||||
- square feet
|
||||
- year built
|
||||
- verdict
|
||||
- fair value range
|
||||
- public-record jurisdiction
|
||||
- assessed total
|
||||
|
||||
4. Body sections
|
||||
- Snapshot
|
||||
- What I Like
|
||||
- What I Do Not Like
|
||||
- Comp View
|
||||
- Underwriting / Carry View
|
||||
- Risks and Diligence Items
|
||||
- Photo Review
|
||||
- Public Records
|
||||
- Source Links
|
||||
|
||||
5. Notes page
|
||||
- report policy note
|
||||
- reminder that listing data should be reconciled against official public records when available
|
||||
|
||||
## Visual rules
|
||||
|
||||
- fixed blue section headers
|
||||
- verdict badge color depends on the decision
|
||||
- same margins, typography, and section ordering for every report
|
||||
- links should be shown explicitly in the PDF
|
||||
- no ad hoc rearranging of sections per run
|
||||
|
||||
## Recipient gate
|
||||
|
||||
The report must not be rendered or sent unless target recipient email address(es) are known.
|
||||
|
||||
If the prompt does not include recipient email(s), stop and ask for them before rendering the PDF.
|
||||
13
skills/property-assessor/scripts/property-assessor
Executable file
13
skills/property-assessor/scripts/property-assessor
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SKILL_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
TSX_BIN="${SKILL_DIR}/node_modules/.bin/tsx"
|
||||
|
||||
if [[ ! -e "${TSX_BIN}" ]]; then
|
||||
echo "Missing local Node dependencies for property-assessor. Run 'cd ${SKILL_DIR} && npm install' first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec node "${TSX_BIN}" "${SKILL_DIR}/src/cli.ts" "$@"
|
||||
63
skills/property-assessor/src/cli.ts
Normal file
63
skills/property-assessor/src/cli.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import minimist from "minimist";
|
||||
|
||||
import { resolvePublicRecords } from "./public-records.js";
|
||||
import { ReportValidationError, loadReportPayload, renderReportPdf } from "./report-pdf.js";
|
||||
|
||||
function usage(): void {
|
||||
process.stdout.write(`property-assessor\n
|
||||
Commands:
|
||||
locate-public-records --address "<address>" [--parcel-id "<id>"] [--listing-geo-id "<id>"] [--listing-source-url "<url>"]
|
||||
render-report --input "<payload.json>" --output "<report.pdf>"
|
||||
`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const argv = minimist(process.argv.slice(2), {
|
||||
string: ["address", "parcel-id", "listing-geo-id", "listing-source-url", "input", "output"],
|
||||
alias: {
|
||||
h: "help"
|
||||
}
|
||||
});
|
||||
const [command] = argv._;
|
||||
|
||||
if (!command || argv.help) {
|
||||
usage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "locate-public-records") {
|
||||
if (!argv.address) {
|
||||
throw new Error("Missing required option: --address");
|
||||
}
|
||||
const payload = await resolvePublicRecords(argv.address, {
|
||||
parcelId: argv["parcel-id"],
|
||||
listingGeoId: argv["listing-geo-id"],
|
||||
listingSourceUrl: argv["listing-source-url"]
|
||||
});
|
||||
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "render-report") {
|
||||
if (!argv.input || !argv.output) {
|
||||
throw new Error("Missing required options: --input and --output");
|
||||
}
|
||||
const payload = await loadReportPayload(argv.input);
|
||||
const outputPath = await renderReportPdf(payload, argv.output);
|
||||
process.stdout.write(`${JSON.stringify({ ok: true, outputPath }, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown command: ${command}`);
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
const message =
|
||||
error instanceof ReportValidationError || error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
292
skills/property-assessor/src/public-records.ts
Normal file
292
skills/property-assessor/src/public-records.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
export const CENSUS_GEOCODER_URL =
|
||||
"https://geocoding.geo.census.gov/geocoder/geographies/onelineaddress";
|
||||
export const TEXAS_COUNTY_DIRECTORY_URL =
|
||||
"https://comptroller.texas.gov/taxes/property-tax/county-directory/";
|
||||
export const TEXAS_PROPERTY_TAX_PORTAL = "https://texas.gov/PropertyTaxes";
|
||||
|
||||
export class PublicRecordsLookupError extends Error {}
|
||||
|
||||
export interface PublicRecordsResolution {
|
||||
requestedAddress: string;
|
||||
matchedAddress: string;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
geoid: string | null;
|
||||
state: {
|
||||
name: string | null;
|
||||
code: string | null;
|
||||
fips: string | null;
|
||||
};
|
||||
county: {
|
||||
name: string | null;
|
||||
fips: string | null;
|
||||
geoid: string | null;
|
||||
};
|
||||
officialLinks: {
|
||||
censusGeocoder: string;
|
||||
texasCountyDirectory: string | null;
|
||||
texasPropertyTaxPortal: string | null;
|
||||
};
|
||||
appraisalDistrict: Record<string, unknown> | null;
|
||||
taxAssessorCollector: Record<string, unknown> | null;
|
||||
lookupRecommendations: string[];
|
||||
sourceIdentifierHints: Record<string, string>;
|
||||
}
|
||||
|
||||
interface FetchLike {
|
||||
(url: string): Promise<string>;
|
||||
}
|
||||
|
||||
const defaultFetchText: FetchLike = async (url) => {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"user-agent": "property-assessor/1.0"
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new PublicRecordsLookupError(`Request failed for ${url}: ${response.status}`);
|
||||
}
|
||||
return await response.text();
|
||||
};
|
||||
|
||||
function collapseWhitespace(value: string | null | undefined): string {
|
||||
return (value || "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function normalizeCountyName(value: string): string {
|
||||
return collapseWhitespace(value)
|
||||
.toLowerCase()
|
||||
.replace(/ county\b/, "")
|
||||
.replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function stripHtml(value: string): string {
|
||||
let output = value
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<[^>]+>/g, "");
|
||||
output = output
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">");
|
||||
output = collapseWhitespace(output.replace(/\n/g, ", "));
|
||||
output = output.replace(/\s*,\s*/g, ", ").replace(/(,\s*){2,}/g, ", ");
|
||||
return output.replace(/^,\s*|\s*,\s*$/g, "");
|
||||
}
|
||||
|
||||
function extractAnchorHref(fragment: string): string | null {
|
||||
const match = fragment.match(/<a[^>]+href="([^"]+)"/i);
|
||||
if (!match) return null;
|
||||
const href = match[1].trim();
|
||||
if (href.startsWith("//")) return `https:${href}`;
|
||||
return href;
|
||||
}
|
||||
|
||||
async function geocodeAddress(address: string, fetchText: FetchLike): Promise<{
|
||||
match: any;
|
||||
censusGeocoderUrl: string;
|
||||
}> {
|
||||
const query = new URLSearchParams({
|
||||
address,
|
||||
benchmark: "Public_AR_Current",
|
||||
vintage: "Current_Current",
|
||||
format: "json"
|
||||
});
|
||||
const url = `${CENSUS_GEOCODER_URL}?${query.toString()}`;
|
||||
const payload = JSON.parse(await fetchText(url));
|
||||
const matches = payload?.result?.addressMatches || [];
|
||||
if (!matches.length) {
|
||||
throw new PublicRecordsLookupError(`No Census geocoder match found for address: ${address}`);
|
||||
}
|
||||
return { match: matches[0], censusGeocoderUrl: url };
|
||||
}
|
||||
|
||||
async function findTexasCountyHref(countyName: string, fetchText: FetchLike): Promise<string> {
|
||||
const html = await fetchText(TEXAS_COUNTY_DIRECTORY_URL);
|
||||
const countyNorm = normalizeCountyName(countyName);
|
||||
const matches = html.matchAll(/<a href="([^"]+\.php)">\s*\d+\s+([^<]+)\s*<\/a>/gi);
|
||||
for (const match of matches) {
|
||||
const href = match[1];
|
||||
const label = match[2];
|
||||
if (normalizeCountyName(label) === countyNorm) {
|
||||
return href.startsWith("http://") || href.startsWith("https://")
|
||||
? href
|
||||
: `${TEXAS_COUNTY_DIRECTORY_URL}${href.replace(/^\/+/, "")}`;
|
||||
}
|
||||
}
|
||||
throw new PublicRecordsLookupError(
|
||||
`Could not find Texas county directory page for county: ${countyName}`
|
||||
);
|
||||
}
|
||||
|
||||
function parseTexasSection(sectionHtml: string): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
const lastUpdated = sectionHtml.match(
|
||||
/<p class="file-info">\s*Last Updated:\s*([^<]+)<\/p>/i
|
||||
);
|
||||
if (lastUpdated) {
|
||||
result.lastUpdated = collapseWhitespace(lastUpdated[1]);
|
||||
}
|
||||
|
||||
const lead = sectionHtml.match(/<h4>\s*([^:<]+):\s*([^<]+)<\/h4>/i);
|
||||
if (lead) {
|
||||
result[lead[1].trim()] = collapseWhitespace(lead[2]);
|
||||
}
|
||||
|
||||
const infoBlock = sectionHtml.match(/<h4>\s*[^<]+<\/h4>\s*<p>(.*?)<\/p>/is);
|
||||
if (infoBlock) {
|
||||
for (const match of infoBlock[1].matchAll(
|
||||
/<strong>\s*([^:<]+):\s*<\/strong>\s*(.*?)(?:<br\s*\/?>|$)/gis
|
||||
)) {
|
||||
const key = collapseWhitespace(match[1]);
|
||||
const rawValue = match[2];
|
||||
const hrefValue = extractAnchorHref(rawValue);
|
||||
if (key.toLowerCase() === "website" && hrefValue) {
|
||||
result[key] = hrefValue;
|
||||
} else if (
|
||||
key.toLowerCase() === "email" &&
|
||||
hrefValue &&
|
||||
hrefValue.startsWith("mailto:")
|
||||
) {
|
||||
result[key] = hrefValue.replace(/^mailto:/i, "");
|
||||
} else {
|
||||
result[key] = stripHtml(rawValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const headings: Array<[string, string]> = [
|
||||
["Mailing Address", "mailingAddress"],
|
||||
["Street Address", "streetAddress"],
|
||||
["Collecting Unit", "collectingUnit"]
|
||||
];
|
||||
|
||||
for (const [heading, key] of headings) {
|
||||
const match = sectionHtml.match(
|
||||
new RegExp(`<h4>\\s*${heading}\\s*<\\/h4>\\s*<p>(.*?)<\\/p>`, "is")
|
||||
);
|
||||
if (match) {
|
||||
result[key] = stripHtml(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function fetchTexasCountyOffices(
|
||||
countyName: string,
|
||||
fetchText: FetchLike
|
||||
): Promise<{
|
||||
directoryPage: string;
|
||||
appraisalDistrict: Record<string, unknown>;
|
||||
taxAssessorCollector: Record<string, unknown> | null;
|
||||
}> {
|
||||
const pageUrl = await findTexasCountyHref(countyName, fetchText);
|
||||
const html = await fetchText(pageUrl);
|
||||
const appraisalMatch = html.match(
|
||||
/<h3>\s*Appraisal District\s*<\/h3>(.*?)(?=<h3>\s*Tax Assessor\/Collector\s*<\/h3>)/is
|
||||
);
|
||||
const taxMatch = html.match(/<h3>\s*Tax Assessor\/Collector\s*<\/h3>(.*)$/is);
|
||||
if (!appraisalMatch) {
|
||||
throw new PublicRecordsLookupError(
|
||||
`Could not parse Appraisal District section for county: ${countyName}`
|
||||
);
|
||||
}
|
||||
|
||||
const appraisalDistrict = parseTexasSection(appraisalMatch[1]);
|
||||
appraisalDistrict.directoryPage = pageUrl;
|
||||
|
||||
const taxAssessorCollector = taxMatch ? parseTexasSection(taxMatch[1]) : null;
|
||||
if (taxAssessorCollector) {
|
||||
taxAssessorCollector.directoryPage = pageUrl;
|
||||
}
|
||||
|
||||
return {
|
||||
directoryPage: pageUrl,
|
||||
appraisalDistrict,
|
||||
taxAssessorCollector
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolvePublicRecords(
|
||||
address: string,
|
||||
options: {
|
||||
parcelId?: string;
|
||||
listingGeoId?: string;
|
||||
listingSourceUrl?: string;
|
||||
fetchText?: FetchLike;
|
||||
} = {}
|
||||
): Promise<PublicRecordsResolution> {
|
||||
const fetchText = options.fetchText || defaultFetchText;
|
||||
const { match, censusGeocoderUrl } = await geocodeAddress(address, fetchText);
|
||||
const geographies = match.geographies || {};
|
||||
const state = (geographies.States || [{}])[0];
|
||||
const county = (geographies.Counties || [{}])[0];
|
||||
const block = (geographies["2020 Census Blocks"] || [{}])[0];
|
||||
const coordinates = match.coordinates || {};
|
||||
|
||||
let texasCountyDirectory: string | null = null;
|
||||
let texasPropertyTaxPortal: string | null = null;
|
||||
let appraisalDistrict: Record<string, unknown> | null = null;
|
||||
let taxAssessorCollector: Record<string, unknown> | null = null;
|
||||
|
||||
const lookupRecommendations = [
|
||||
"Start from the official public-record jurisdiction instead of a listing-site geo ID.",
|
||||
"Try official address search first on the appraisal district site.",
|
||||
"If the listing exposes parcel/APN/account identifiers, use them as stronger search keys than ZPID or listing geo IDs."
|
||||
];
|
||||
|
||||
if (state.STUSAB === "TX" && county.NAME) {
|
||||
const offices = await fetchTexasCountyOffices(county.NAME, fetchText);
|
||||
texasCountyDirectory = offices.directoryPage;
|
||||
texasPropertyTaxPortal = TEXAS_PROPERTY_TAX_PORTAL;
|
||||
appraisalDistrict = offices.appraisalDistrict;
|
||||
taxAssessorCollector = offices.taxAssessorCollector;
|
||||
lookupRecommendations.push(
|
||||
"Use the Texas Comptroller county directory page as the official jurisdiction link in the final report.",
|
||||
"Attempt to retrieve assessed value, land value, improvement value, exemptions, and account number from the CAD website when a direct property page is publicly accessible."
|
||||
);
|
||||
}
|
||||
|
||||
const sourceIdentifierHints: Record<string, string> = {};
|
||||
if (options.parcelId) sourceIdentifierHints.parcelId = options.parcelId;
|
||||
if (options.listingGeoId) {
|
||||
sourceIdentifierHints.listingGeoId = options.listingGeoId;
|
||||
lookupRecommendations.push(
|
||||
"Treat listing geo IDs as regional hints only; do not use them as assessor record keys."
|
||||
);
|
||||
}
|
||||
if (options.listingSourceUrl) {
|
||||
sourceIdentifierHints.listingSourceUrl = options.listingSourceUrl;
|
||||
}
|
||||
|
||||
return {
|
||||
requestedAddress: address,
|
||||
matchedAddress: match.matchedAddress || address,
|
||||
latitude: coordinates.y ?? null,
|
||||
longitude: coordinates.x ?? null,
|
||||
geoid: block.GEOID || null,
|
||||
state: {
|
||||
name: state.NAME || null,
|
||||
code: state.STUSAB || null,
|
||||
fips: state.STATE || null
|
||||
},
|
||||
county: {
|
||||
name: county.NAME || null,
|
||||
fips: county.COUNTY || null,
|
||||
geoid: county.GEOID || null
|
||||
},
|
||||
officialLinks: {
|
||||
censusGeocoder: censusGeocoderUrl,
|
||||
texasCountyDirectory,
|
||||
texasPropertyTaxPortal
|
||||
},
|
||||
appraisalDistrict,
|
||||
taxAssessorCollector,
|
||||
lookupRecommendations,
|
||||
sourceIdentifierHints
|
||||
};
|
||||
}
|
||||
343
skills/property-assessor/src/report-pdf.ts
Normal file
343
skills/property-assessor/src/report-pdf.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import PDFDocument from "pdfkit";
|
||||
|
||||
export class ReportValidationError extends Error {}
|
||||
|
||||
export interface ReportPayload {
|
||||
recipientEmails?: string[] | string;
|
||||
reportTitle?: string;
|
||||
subtitle?: string;
|
||||
generatedAt?: string;
|
||||
preparedBy?: string;
|
||||
reportNotes?: string;
|
||||
subjectProperty?: Record<string, unknown>;
|
||||
verdict?: Record<string, unknown>;
|
||||
snapshot?: unknown;
|
||||
whatILike?: unknown;
|
||||
whatIDontLike?: unknown;
|
||||
compView?: unknown;
|
||||
carryView?: unknown;
|
||||
risksAndDiligence?: unknown;
|
||||
photoReview?: Record<string, unknown>;
|
||||
publicRecords?: Record<string, unknown>;
|
||||
sourceLinks?: unknown;
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
if (value == null) return [];
|
||||
if (typeof value === "string") {
|
||||
const text = value.trim();
|
||||
return text ? [text] : [];
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return [String(value)];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const out: string[] = [];
|
||||
for (const item of value) {
|
||||
out.push(...asStringArray(item));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, item]) => item != null && item !== "")
|
||||
.map(([key, item]) => `${key}: ${item}`);
|
||||
}
|
||||
return [String(value)];
|
||||
}
|
||||
|
||||
function currency(value: unknown): string {
|
||||
if (value == null || value === "") return "N/A";
|
||||
const num = Number(value);
|
||||
if (Number.isFinite(num)) return `$${num.toLocaleString("en-US", { maximumFractionDigits: 0 })}`;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function validateReportPayload(payload: ReportPayload): string[] {
|
||||
const recipients = asStringArray(payload.recipientEmails).map((item) => item.trim()).filter(Boolean);
|
||||
if (!recipients.length) {
|
||||
throw new ReportValidationError(
|
||||
"Missing target email. Stop and ask the user for target email address(es) before generating or sending the property assessment PDF."
|
||||
);
|
||||
}
|
||||
const address = payload.subjectProperty && typeof payload.subjectProperty.address === "string"
|
||||
? payload.subjectProperty.address.trim()
|
||||
: "";
|
||||
if (!address) {
|
||||
throw new ReportValidationError("The report payload must include subjectProperty.address.");
|
||||
}
|
||||
return recipients;
|
||||
}
|
||||
|
||||
function drawSectionHeader(doc: PDFKit.PDFDocument, title: string): void {
|
||||
const x = doc.page.margins.left;
|
||||
const y = doc.y;
|
||||
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||
const height = 18;
|
||||
doc.save();
|
||||
doc.roundedRect(x, y, width, height, 3).fill("#123B5D");
|
||||
doc
|
||||
.fillColor("white")
|
||||
.font("Helvetica-Bold")
|
||||
.fontSize(12)
|
||||
.text(title, x + 8, y + 4, { width: width - 16 });
|
||||
doc.restore();
|
||||
doc.moveDown(1.2);
|
||||
}
|
||||
|
||||
function drawBulletList(doc: PDFKit.PDFDocument, value: unknown, fallback = "Not provided."): void {
|
||||
const items = asStringArray(value);
|
||||
if (!items.length) {
|
||||
doc.fillColor("#1E2329").font("Helvetica").fontSize(10.5).text(fallback);
|
||||
doc.moveDown(0.6);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const startY = doc.y;
|
||||
doc.circle(doc.page.margins.left + 4, startY + 6, 1.5).fill("#123B5D");
|
||||
doc
|
||||
.fillColor("#1E2329")
|
||||
.font("Helvetica")
|
||||
.fontSize(10.5)
|
||||
.text(item, doc.page.margins.left + 14, startY, {
|
||||
width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14,
|
||||
lineGap: 2
|
||||
});
|
||||
doc.moveDown(0.35);
|
||||
}
|
||||
doc.moveDown(0.2);
|
||||
}
|
||||
|
||||
function drawKeyValueTable(doc: PDFKit.PDFDocument, rows: Array<[string, string]>): void {
|
||||
const left = doc.page.margins.left;
|
||||
const totalWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||
const keyWidth = 150;
|
||||
const valueWidth = totalWidth - keyWidth;
|
||||
for (const [key, value] of rows) {
|
||||
const startY = doc.y;
|
||||
const keyHeight = doc.heightOfString(key, { width: keyWidth - 12 });
|
||||
const valueHeight = doc.heightOfString(value, { width: valueWidth - 12 });
|
||||
const rowHeight = Math.max(keyHeight, valueHeight) + 12;
|
||||
|
||||
doc.save();
|
||||
doc.rect(left, startY, keyWidth, rowHeight).fill("#EEF3F7");
|
||||
doc.rect(left + keyWidth, startY, valueWidth, rowHeight).fill("#FFFFFF");
|
||||
doc
|
||||
.lineWidth(0.5)
|
||||
.strokeColor("#C7D0D9")
|
||||
.rect(left, startY, totalWidth, rowHeight)
|
||||
.stroke();
|
||||
doc
|
||||
.moveTo(left + keyWidth, startY)
|
||||
.lineTo(left + keyWidth, startY + rowHeight)
|
||||
.stroke();
|
||||
doc.restore();
|
||||
|
||||
doc.fillColor("#1E2329").font("Helvetica-Bold").fontSize(9.5).text(key, left + 6, startY + 6, { width: keyWidth - 12 });
|
||||
doc.fillColor("#1E2329").font("Helvetica").fontSize(9.5).text(value, left + keyWidth + 6, startY + 6, { width: valueWidth - 12 });
|
||||
doc.y = startY + rowHeight;
|
||||
}
|
||||
doc.moveDown(0.8);
|
||||
}
|
||||
|
||||
function drawVerdictPanel(doc: PDFKit.PDFDocument, verdict: Record<string, unknown> | undefined): void {
|
||||
const decision = String(verdict?.decision || "pending").trim().toLowerCase();
|
||||
const badgeColor =
|
||||
decision === "buy" ? "#1E6B52" : decision === "pass" ? "#8B2E2E" : "#7A5D12";
|
||||
const left = doc.page.margins.left;
|
||||
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||
const top = doc.y;
|
||||
const bodyText = String(
|
||||
verdict?.offerGuidance || "Offer guidance not provided."
|
||||
);
|
||||
const bodyHeight = doc.heightOfString(bodyText, { width: width - 20 }) + 16;
|
||||
|
||||
doc.save();
|
||||
doc.roundedRect(left, top, width, 26, 4).fill(badgeColor);
|
||||
doc
|
||||
.fillColor("white")
|
||||
.font("Helvetica-Bold")
|
||||
.fontSize(12)
|
||||
.text(String(verdict?.decision || "N/A").toUpperCase(), left + 10, top + 7, {
|
||||
width: width - 20
|
||||
});
|
||||
|
||||
doc
|
||||
.roundedRect(left, top + 26, width, bodyHeight, 4)
|
||||
.fillAndStroke("#F4F6F8", "#C7D0D9");
|
||||
doc
|
||||
.fillColor("#1E2329")
|
||||
.font("Helvetica")
|
||||
.fontSize(10.5)
|
||||
.text(bodyText, left + 10, top + 36, {
|
||||
width: width - 20,
|
||||
lineGap: 2
|
||||
});
|
||||
doc.restore();
|
||||
doc.y = top + 26 + bodyHeight + 10;
|
||||
}
|
||||
|
||||
function drawLinks(doc: PDFKit.PDFDocument, value: unknown): void {
|
||||
const items = Array.isArray(value) ? value : [];
|
||||
if (!items.length) {
|
||||
drawBulletList(doc, [], "Not provided.");
|
||||
return;
|
||||
}
|
||||
for (const item of items as Array<Record<string, unknown>>) {
|
||||
const label = typeof item.label === "string" ? item.label : "Link";
|
||||
const url = typeof item.url === "string" ? item.url : "";
|
||||
const line = url ? `${label}: ${url}` : label;
|
||||
const startY = doc.y;
|
||||
doc.circle(doc.page.margins.left + 4, startY + 6, 1.5).fill("#123B5D");
|
||||
doc.fillColor("#1E2329").font("Helvetica").fontSize(10.5);
|
||||
if (url) {
|
||||
doc.text(line, doc.page.margins.left + 14, startY, {
|
||||
width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14,
|
||||
lineGap: 2,
|
||||
link: url,
|
||||
underline: true
|
||||
});
|
||||
} else {
|
||||
doc.text(line, doc.page.margins.left + 14, startY, {
|
||||
width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14,
|
||||
lineGap: 2
|
||||
});
|
||||
}
|
||||
doc.moveDown(0.35);
|
||||
}
|
||||
doc.moveDown(0.2);
|
||||
}
|
||||
|
||||
export async function renderReportPdf(
|
||||
payload: ReportPayload,
|
||||
outputPath: string
|
||||
): Promise<string> {
|
||||
const recipients = validateReportPayload(payload);
|
||||
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
|
||||
const doc = new PDFDocument({
|
||||
size: "LETTER",
|
||||
margin: 50,
|
||||
info: {
|
||||
Title: payload.reportTitle || "Property Assessment Report",
|
||||
Author: String(payload.preparedBy || "OpenClaw property-assessor")
|
||||
}
|
||||
});
|
||||
|
||||
const stream = fs.createWriteStream(outputPath);
|
||||
doc.pipe(stream);
|
||||
|
||||
const generatedAt =
|
||||
payload.generatedAt ||
|
||||
new Date().toLocaleString("en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short"
|
||||
});
|
||||
const subject = payload.subjectProperty || {};
|
||||
const verdict = payload.verdict || {};
|
||||
const publicRecords = payload.publicRecords || {};
|
||||
|
||||
doc.fillColor("#123B5D").font("Helvetica-Bold").fontSize(22).text(payload.reportTitle || "Property Assessment Report");
|
||||
doc.moveDown(0.2);
|
||||
doc.fillColor("#1E2329").font("Helvetica").fontSize(11).text(
|
||||
String(
|
||||
payload.subtitle ||
|
||||
"Decision-grade acquisition review with listing, public-record, comp, and risk analysis."
|
||||
)
|
||||
);
|
||||
doc.moveDown(0.4);
|
||||
doc.fillColor("#5A6570").font("Helvetica").fontSize(9);
|
||||
doc.text(`Prepared for: ${recipients.join(", ")}`);
|
||||
doc.text(`Generated: ${generatedAt}`);
|
||||
doc.moveDown(0.8);
|
||||
|
||||
drawVerdictPanel(doc, verdict);
|
||||
|
||||
drawKeyValueTable(doc, [
|
||||
["Address", String(subject.address || "N/A")],
|
||||
["Ask / Last Price", currency(subject.listingPrice)],
|
||||
["Type", String(subject.propertyType || "N/A")],
|
||||
["Beds / Baths", `${subject.beds ?? "N/A"} / ${subject.baths ?? "N/A"}`],
|
||||
["Sqft", String(subject.squareFeet ?? "N/A")],
|
||||
["Year Built", String(subject.yearBuilt ?? "N/A")],
|
||||
["Verdict", String(verdict.decision || "N/A")],
|
||||
["Fair Value Range", String(verdict.fairValueRange || "N/A")],
|
||||
["Public-Record Jurisdiction", String(publicRecords.jurisdiction || "N/A")],
|
||||
["Assessed Total", currency(publicRecords.assessedTotalValue)]
|
||||
]);
|
||||
|
||||
const sections: Array<[string, unknown, "list" | "links"]> = [
|
||||
["Snapshot", payload.snapshot, "list"],
|
||||
["What I Like", payload.whatILike, "list"],
|
||||
["What I Do Not Like", payload.whatIDontLike, "list"],
|
||||
["Comp View", payload.compView, "list"],
|
||||
["Underwriting / Carry View", payload.carryView, "list"],
|
||||
["Risks and Diligence Items", payload.risksAndDiligence, "list"],
|
||||
[
|
||||
"Photo Review",
|
||||
[
|
||||
...(asStringArray((payload.photoReview || {}).status ? [`Photo review: ${String((payload.photoReview || {}).status)}${(payload.photoReview || {}).source ? ` via ${String((payload.photoReview || {}).source)}` : ""}`] : [])),
|
||||
...asStringArray((payload.photoReview || {}).attempts),
|
||||
...asStringArray((payload.photoReview || {}).summary ? [`Condition read: ${String((payload.photoReview || {}).summary)}`] : [])
|
||||
],
|
||||
"list"
|
||||
],
|
||||
[
|
||||
"Public Records",
|
||||
[
|
||||
...asStringArray({
|
||||
Jurisdiction: publicRecords.jurisdiction,
|
||||
"Account Number": publicRecords.accountNumber,
|
||||
"Owner Name": publicRecords.ownerName,
|
||||
"Land Value": publicRecords.landValue != null ? currency(publicRecords.landValue) : undefined,
|
||||
"Improvement Value":
|
||||
publicRecords.improvementValue != null ? currency(publicRecords.improvementValue) : undefined,
|
||||
"Assessed Total":
|
||||
publicRecords.assessedTotalValue != null ? currency(publicRecords.assessedTotalValue) : undefined,
|
||||
Exemptions: publicRecords.exemptions
|
||||
}),
|
||||
...asStringArray((publicRecords.links || []).map((item: any) => `${item.label}: ${item.url}`))
|
||||
],
|
||||
"list"
|
||||
],
|
||||
["Source Links", payload.sourceLinks, "links"]
|
||||
];
|
||||
|
||||
for (const [title, content, kind] of sections) {
|
||||
if (doc.y > 660) doc.addPage();
|
||||
drawSectionHeader(doc, title);
|
||||
if (kind === "links") {
|
||||
drawLinks(doc, content);
|
||||
} else {
|
||||
drawBulletList(doc, content);
|
||||
}
|
||||
}
|
||||
|
||||
doc.addPage();
|
||||
drawSectionHeader(doc, "Report Notes");
|
||||
doc.fillColor("#1E2329").font("Helvetica").fontSize(10.5).text(
|
||||
String(
|
||||
payload.reportNotes ||
|
||||
"This report uses the property-assessor fixed PDF template. Listing data should be reconciled against official public records when available, and public-record links should be included in any delivered report."
|
||||
),
|
||||
{
|
||||
lineGap: 3
|
||||
}
|
||||
);
|
||||
|
||||
doc.end();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.on("finish", () => resolve());
|
||||
stream.on("error", reject);
|
||||
});
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
export async function loadReportPayload(inputPath: string): Promise<ReportPayload> {
|
||||
return JSON.parse(await fs.promises.readFile(inputPath, "utf8")) as ReportPayload;
|
||||
}
|
||||
82
skills/property-assessor/tests/public-records.test.ts
Normal file
82
skills/property-assessor/tests/public-records.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolvePublicRecords } from "../src/public-records.js";
|
||||
|
||||
const geocoderPayload = {
|
||||
result: {
|
||||
addressMatches: [
|
||||
{
|
||||
matchedAddress: "4141 WHITELEY DR, CORPUS CHRISTI, TX, 78418",
|
||||
coordinates: { x: -97.30174, y: 27.613668 },
|
||||
geographies: {
|
||||
States: [{ NAME: "Texas", STUSAB: "TX", STATE: "48" }],
|
||||
Counties: [{ NAME: "Nueces County", COUNTY: "355", GEOID: "48355" }],
|
||||
"2020 Census Blocks": [{ GEOID: "483550031013005" }]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const countyIndexHtml = `
|
||||
<ul>
|
||||
<li><a href="nueces.php">178 Nueces</a></li>
|
||||
</ul>
|
||||
`;
|
||||
|
||||
const countyPageHtml = `
|
||||
<div class="medium-6 small-12 columns">
|
||||
<h3>Appraisal District</h3>
|
||||
<p class="file-info">Last Updated: 08/13/2025</p>
|
||||
<h4>Chief Appraiser: Debra Morin, Interim</h4>
|
||||
<p>
|
||||
<strong>Phone:</strong> <a href="tel:361-881-9978">361-881-9978</a><br />
|
||||
<strong>Email:</strong> <a href="mailto:info@nuecescad.net">info@nuecescad.net</a><br />
|
||||
<strong>Website:</strong> <a href="http://www.ncadistrict.com/">www.ncadistrict.com</a>
|
||||
</p>
|
||||
<h4>Mailing Address</h4>
|
||||
<p>201 N. Chaparral St.<br />Corpus Christi, TX 78401-2503</p>
|
||||
</div>
|
||||
<div class="medium-6 small-12 columns">
|
||||
<h3>Tax Assessor/Collector</h3>
|
||||
<p class="file-info">Last Updated: 02/18/2025</p>
|
||||
<h4>Tax Assessor-Collector: Kevin Kieschnick</h4>
|
||||
<p>
|
||||
<strong>Phone:</strong> <a href="tel:361-888-0307">361-888-0307</a><br />
|
||||
<strong>Email:</strong> <a href="mailto:nueces.tax@nuecesco.com">nueces.tax@nuecesco.com</a><br />
|
||||
<strong>Website:</strong> <a href="http://www.nuecesco.com">www.nuecesco.com</a>
|
||||
</p>
|
||||
<h4>Street Address</h4>
|
||||
<p>901 Leopard St., Room 301<br />Corpus Christi, Texas 78401-3602</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const fakeFetchText = async (url: string): Promise<string> => {
|
||||
if (url.includes("geocoding.geo.census.gov")) {
|
||||
return JSON.stringify(geocoderPayload);
|
||||
}
|
||||
if (url.endsWith("/county-directory/")) {
|
||||
return countyIndexHtml;
|
||||
}
|
||||
if (url.endsWith("/county-directory/nueces.php")) {
|
||||
return countyPageHtml;
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
};
|
||||
|
||||
test("resolvePublicRecords uses Census and Texas county directory", async () => {
|
||||
const payload = await resolvePublicRecords("4141 Whiteley Dr, Corpus Christi, TX 78418", {
|
||||
parcelId: "14069438",
|
||||
listingGeoId: "233290",
|
||||
listingSourceUrl: "https://www.zillow.com/homedetails/example",
|
||||
fetchText: fakeFetchText
|
||||
});
|
||||
|
||||
assert.equal(payload.county.name, "Nueces County");
|
||||
assert.equal(payload.state.code, "TX");
|
||||
assert.equal(payload.appraisalDistrict?.Website, "http://www.ncadistrict.com/");
|
||||
assert.equal(payload.taxAssessorCollector?.Email, "nueces.tax@nuecesco.com");
|
||||
assert.equal(payload.sourceIdentifierHints.parcelId, "14069438");
|
||||
assert.match(payload.lookupRecommendations.join(" "), /listing geo IDs as regional hints only/i);
|
||||
});
|
||||
69
skills/property-assessor/tests/report-pdf.test.ts
Normal file
69
skills/property-assessor/tests/report-pdf.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { ReportValidationError, renderReportPdf } from "../src/report-pdf.js";
|
||||
|
||||
const samplePayload = {
|
||||
recipientEmails: ["buyer@example.com"],
|
||||
reportTitle: "Property Assessment Report",
|
||||
subjectProperty: {
|
||||
address: "4141 Whiteley Dr, Corpus Christi, TX 78418",
|
||||
listingPrice: 149900,
|
||||
propertyType: "Townhouse",
|
||||
beds: 2,
|
||||
baths: 2,
|
||||
squareFeet: 900,
|
||||
yearBuilt: 1978
|
||||
},
|
||||
verdict: {
|
||||
decision: "only below x",
|
||||
fairValueRange: "$132,000 - $138,000",
|
||||
offerGuidance:
|
||||
"Only attractive below ask after HOA, insurance, and medium make-ready assumptions are priced in."
|
||||
},
|
||||
snapshot: ["2 bed / 2 bath coastal townhouse in Flour Bluff."],
|
||||
whatILike: ["Compact layout with usable bedroom count for the size."],
|
||||
whatIDontLike: ["Thin margin at the current ask."],
|
||||
compView: ["Need same-building or local comp confirmation."],
|
||||
carryView: ["Underwrite taxes, HOA, wind/flood exposure, and maintenance together."],
|
||||
risksAndDiligence: ["Confirm reserve strength and special assessment history."],
|
||||
photoReview: {
|
||||
status: "completed",
|
||||
source: "Zillow",
|
||||
attempts: ["Zillow all-photo extractor returned the full 29-photo set."],
|
||||
summary: "Interior reads dated-to-average rather than turnkey."
|
||||
},
|
||||
publicRecords: {
|
||||
jurisdiction: "Nueces Appraisal District",
|
||||
assessedTotalValue: 141000,
|
||||
links: [{ label: "Nueces CAD", url: "http://www.ncadistrict.com/" }]
|
||||
},
|
||||
sourceLinks: [
|
||||
{ label: "Zillow Listing", url: "https://www.zillow.com/homedetails/example" }
|
||||
]
|
||||
};
|
||||
|
||||
test("renderReportPdf writes a non-empty PDF", async () => {
|
||||
const outputPath = path.join(os.tmpdir(), `property-assessor-${Date.now()}.pdf`);
|
||||
await renderReportPdf(samplePayload, outputPath);
|
||||
const stat = await fs.promises.stat(outputPath);
|
||||
assert.ok(stat.size > 1000);
|
||||
});
|
||||
|
||||
test("renderReportPdf requires recipient email", async () => {
|
||||
const outputPath = path.join(os.tmpdir(), `property-assessor-missing-email-${Date.now()}.pdf`);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
renderReportPdf(
|
||||
{
|
||||
...samplePayload,
|
||||
recipientEmails: []
|
||||
},
|
||||
outputPath
|
||||
),
|
||||
ReportValidationError
|
||||
);
|
||||
});
|
||||
17
skills/property-assessor/tsconfig.json
Normal file
17
skills/property-assessor/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user