Port property assessor helpers to TypeScript
This commit is contained in:
@@ -1,38 +1,44 @@
|
|||||||
# property-assessor
|
# property-assessor
|
||||||
|
|
||||||
Decision-grade residential property assessment skill for OpenClaw.
|
Decision-grade residential property assessment skill for OpenClaw, with official public-record enrichment and fixed-template PDF report rendering.
|
||||||
|
|
||||||
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`.
|
|
||||||
|
|
||||||
## Overview
|
## 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
|
The skill is intended to:
|
||||||
- build a baseline fact set
|
|
||||||
|
- normalize the property across listing sources
|
||||||
- review listing photos before making condition claims
|
- review listing photos before making condition claims
|
||||||
- compare the property against nearby or same-building comps
|
- incorporate official public-record / appraisal-district context when available
|
||||||
- underwrite taxes, HOA, insurance, and realistic carrying costs
|
- compare the property against comps and carrying costs
|
||||||
- identify risk drivers
|
- produce a fixed-format PDF report, not just ad hoc chat prose
|
||||||
- produce a concise but decision-grade verdict
|
|
||||||
|
|
||||||
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
|
From `skills/property-assessor/`:
|
||||||
- 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
|
|
||||||
|
|
||||||
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
|
## Core workflow
|
||||||
|
|
||||||
@@ -40,16 +46,15 @@ Default operating sequence:
|
|||||||
|
|
||||||
1. Normalize the address and property type.
|
1. Normalize the address and property type.
|
||||||
2. Discover accessible listing and public-record sources for the same property.
|
2. Discover accessible listing and public-record sources for the same property.
|
||||||
3. Establish a baseline fact set from the best available source.
|
3. Build a baseline fact set.
|
||||||
4. Cross-check the same property on other sources.
|
4. Review listing photos before making condition claims.
|
||||||
5. Review listing photos before making condition claims.
|
5. Pull same-building or nearby comps.
|
||||||
6. Pull same-building comps for condos or nearby comps for houses/townhomes.
|
6. Underwrite carry costs and risk factors.
|
||||||
7. Underwrite carry costs and risk drivers.
|
7. Render the final report as a fixed-template PDF.
|
||||||
8. End with a specific recommendation and fair-value range.
|
|
||||||
|
|
||||||
## Source priority
|
## Source priority
|
||||||
|
|
||||||
Unless the user asks otherwise, preferred source order is:
|
Unless the user says otherwise, preferred listing/source order is:
|
||||||
|
|
||||||
1. Zillow
|
1. Zillow
|
||||||
2. Redfin
|
2. Redfin
|
||||||
@@ -57,11 +62,72 @@ Unless the user asks otherwise, preferred source order is:
|
|||||||
4. HAR / Homes.com / brokerage mirrors
|
4. HAR / Homes.com / brokerage mirrors
|
||||||
5. county or appraisal pages
|
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
|
## 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
|
- address
|
||||||
- list price or last known list price
|
- 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
|
- subdivision or building name
|
||||||
- same-building or nearby active inventory
|
- same-building or nearby active inventory
|
||||||
- listing photos and visible condition cues
|
- listing photos and visible condition cues
|
||||||
- included appliances and obvious missing appliances
|
- public-record jurisdiction and linked official source
|
||||||
- flooring mix, especially carpet
|
- 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.
|
Do not make strong condition claims from structured text alone if photos are available.
|
||||||
|
|
||||||
### What counts as acceptable photo access
|
Preferred photo-access order:
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
1. Zillow extractor
|
1. Zillow extractor
|
||||||
2. HAR extractor
|
2. HAR extractor
|
||||||
3. Realtor.com photo page
|
3. Realtor.com photo page
|
||||||
4. brokerage mirror or other accessible listing mirror
|
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
|
```bash
|
||||||
|
cd ~/.openclaw/workspace/skills/web-automation/scripts
|
||||||
For chat-driven property assessments, prefer file-based commands under:
|
node zillow-photos.js "<zillow-listing-url>"
|
||||||
|
node har-photos.js "<har-listing-url>"
|
||||||
```text
|
|
||||||
~/.openclaw/workspace/skills/web-automation/scripts
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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 check-install.js`
|
||||||
- `node zillow-photos.js "<url>"`
|
- `node zillow-photos.js "<url>"`
|
||||||
- `node har-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:
|
Avoid when possible:
|
||||||
|
|
||||||
- `node -e "..."`
|
- `node -e "..."`
|
||||||
- `node --input-type=module -e "..."`
|
- `node --input-type=module -e "..."`
|
||||||
|
|
||||||
Why this matters:
|
## PDF report template
|
||||||
|
|
||||||
- OpenClaw exec approvals are easier to allowlist for stable file paths
|
The final deliverable should be a fixed-template PDF, not a one-off layout.
|
||||||
- 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
|
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
- paint, fixtures, minor hardware, small patching
|
scripts/property-assessor render-report --input "<report-payload-json>" --output "<output-pdf>"
|
||||||
- medium make-ready
|
```
|
||||||
- partial flooring replacement, appliance replacement, bathroom refresh
|
|
||||||
- heavy make-ready
|
|
||||||
- significant kitchen/bath work, widespread flooring, visible deferred maintenance
|
|
||||||
|
|
||||||
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
|
The report must not be rendered or sent unless target recipient email address(es) are known.
|
||||||
- taxes per month
|
|
||||||
- HOA per month if applicable
|
|
||||||
- insurance estimate or explicit uncertainty
|
|
||||||
- realistic carry range after maintenance, vacancy, and property-specific risk
|
|
||||||
|
|
||||||
Strong caution flags include:
|
If the prompt does not include recipient email(s), the skill should:
|
||||||
|
|
||||||
- high HOA relative to price or expected rent
|
- stop
|
||||||
- older waterfront or coastal exposure
|
- ask for target recipient email address(es)
|
||||||
- unknown reserve or assessment history for condos
|
- not finalize the PDF workflow yet
|
||||||
- many active units in the same building or micro-area
|
|
||||||
- stale days on market with weak price action
|
The renderer enforces this. If `recipientEmails` is missing or empty, it fails with:
|
||||||
- no clear rent support
|
|
||||||
|
`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
|
## 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
|
1. Snapshot
|
||||||
2. What I like
|
2. What I like
|
||||||
@@ -251,76 +255,77 @@ Recommended structure:
|
|||||||
6. Risks and diligence items
|
6. Risks and diligence items
|
||||||
7. Verdict with fair-value range and offer guidance
|
7. Verdict with fair-value range and offer guidance
|
||||||
|
|
||||||
The output must explicitly include:
|
It must also explicitly include:
|
||||||
|
|
||||||
- `Photo source attempts: ...`
|
- `Photo source attempts: ...`
|
||||||
- `Photo review: completed via <source>`
|
- `Photo review: completed via <source>` or `Photo review: not completed`
|
||||||
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
|
### 1. Install the helper package locally
|
||||||
- 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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/.openclaw/workspace/skills/web-automation/scripts
|
cd ~/.openclaw/workspace/skills/property-assessor
|
||||||
node check-install.js
|
npm install
|
||||||
npm run test:photos
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
### Zillow regression check
|
### 2. Run public-record lookup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/.openclaw/workspace/skills/web-automation/scripts
|
cd ~/.openclaw/workspace/skills/property-assessor
|
||||||
node zillow-photos.js "https://www.zillow.com/homedetails/4141-Whiteley-Dr-Corpus-Christi-TX-78418/2103723704_zpid/"
|
scripts/property-assessor locate-public-records --address "4141 Whiteley Dr, Corpus Christi, TX 78418"
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected shape:
|
Expected shape:
|
||||||
|
|
||||||
- `complete: true`
|
- state/county/FIPS/GEOID present
|
||||||
- `expectedPhotoCount: 29`
|
- official Census geocoder link present
|
||||||
- `photoCount: 29`
|
- 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
|
```bash
|
||||||
cd ~/.openclaw/workspace/skills/web-automation/scripts
|
cd ~/.openclaw/workspace/skills/property-assessor
|
||||||
node har-photos.js "https://www.har.com/homedetail/4141-whiteley-dr-corpus-christi-tx-78418/14069438"
|
scripts/property-assessor render-report --input examples/report-payload.example.json --output /tmp/property-assessment.pdf
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected shape:
|
Expected result:
|
||||||
|
|
||||||
- `complete: true`
|
- JSON success payload with `outputPath`
|
||||||
- `expectedPhotoCount: 29`
|
- a non-empty PDF written to `/tmp/property-assessment.pdf`
|
||||||
- `photoCount: 29`
|
|
||||||
|
|
||||||
### 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
|
Expected result:
|
||||||
- falls back to HAR if needed
|
|
||||||
- references actual photo access, not just listing text
|
- non-zero exit
|
||||||
- includes the required `Photo source attempts` line
|
- explicit message telling the operator to stop and ask for target recipient email(s)
|
||||||
- includes the required `Photo review` line
|
|
||||||
- makes condition claims consistent with the reviewed image set
|
### 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
|
## Related files
|
||||||
|
|
||||||
- installed skill instructions:
|
- skill instructions:
|
||||||
- `~/.openclaw/workspace/skills/property-assessor/SKILL.md`
|
|
||||||
- repo skill instructions:
|
|
||||||
- `skills/property-assessor/SKILL.md`
|
- `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`
|
- `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 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.
|
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
|
## Minimum data to capture
|
||||||
|
|
||||||
For the target property, capture when available:
|
For the target property, capture when available:
|
||||||
@@ -74,6 +137,9 @@ For the target property, capture when available:
|
|||||||
- listing photos and visible condition cues
|
- listing photos and visible condition cues
|
||||||
- included appliances and obvious missing appliances
|
- included appliances and obvious missing appliances
|
||||||
- flooring mix, especially whether carpet is present
|
- 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
|
## 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 completed, briefly summarize the condition read from the photos.
|
||||||
If not completed, mark condition confidence as limited and say why.
|
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
|
## 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.
|
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
|
6. Risks and diligence items
|
||||||
7. Verdict with fair value range and offer guidance
|
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
|
## Reuse notes
|
||||||
|
|
||||||
When condos are involved, same-building comps and HOA economics usually matter more than neighborhood averages.
|
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