Port property assessor helpers to TypeScript

This commit is contained in:
2026-03-27 22:23:58 -05:00
parent 954374ce48
commit e6d987d725
14 changed files with 2155 additions and 202 deletions

3
skills/property-assessor/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
.venv/

View File

@@ -53,6 +53,69 @@ Prefer this order unless the user says otherwise:
Use the `web-automation` skill for rendered pages and anti-bot-heavy sites.
Use `web_search` sparingly to discover alternate URLs, then return to `web-automation` for extraction.
## Helper runtime
`property-assessor` now includes TypeScript helper commands for:
- public-record jurisdiction lookup
- fixed-template PDF rendering
Before using those helper commands:
```bash
cd ~/.openclaw/workspace/skills/property-assessor
npm install
```
## Public-record enrichment
Public-record / assessor data should be used when available and linked in the final result.
Default approach:
1. start from the street address
2. resolve the address to county/state/geography
3. identify the related appraisal district / assessor jurisdiction
4. use the official public-record site as a primary-source check against listing data
5. link the official jurisdiction page and any direct property page used in the final result
Use the helper CLI first:
```bash
cd ~/.openclaw/workspace/skills/property-assessor
npm install
scripts/property-assessor locate-public-records --address "<street-address>"
```
This command currently:
- resolves the address through the official Census geocoder
- returns county/state/FIPS/GEOID context
- for Texas, resolves the official Texas Comptroller county directory page
- returns the county appraisal district and tax assessor/collector links when available
Important rules:
- listing-site geo IDs are hints only; do **not** treat them as assessor record keys
- parcel/APN/account identifiers from Zillow/HAR/Redfin are much stronger keys than listing geo IDs
- if a direct public-record property page is available, use its data in the assessment and link it explicitly
- if the jurisdiction can be identified but the property detail page is not directly retrievable, still link the official jurisdiction page and say what could not be confirmed
### Texas rule
For Texas properties, public-record enrichment is required when feasible.
Process:
1. run `locate-public-records` from the subject address
2. use the returned Texas Comptroller county directory page as the official jurisdiction reference
3. use the returned CAD website for address / account / parcel lookup
4. when accessible, capture:
- account number
- owner name
- land value
- improvement value
- assessed total
- exemptions
- tax office links
In the final assessment, explicitly label official public-record facts as such.
## Minimum data to capture
For the target property, capture when available:
@@ -74,6 +137,9 @@ For the target property, capture when available:
- listing photos and visible condition cues
- included appliances and obvious missing appliances
- flooring mix, especially whether carpet is present
- public-record jurisdiction and linked official source
- account / parcel / tax ID if confirmed
- official assessed values and exemptions if confirmed
## Photo and condition review
@@ -217,6 +283,51 @@ The final assessment must explicitly include these lines in the output:
If completed, briefly summarize the condition read from the photos.
If not completed, mark condition confidence as limited and say why.
## PDF report requirement
The deliverable is not just chat text. A fixed-template PDF report must be generated for completed assessments.
Use the property-assessor helper CLI:
```bash
cd ~/.openclaw/workspace/skills/property-assessor
npm install
scripts/property-assessor render-report --input "<report-payload-json>" --output "<output-pdf>"
```
The renderer uses a fixed template and must keep the same look across runs.
Template rules are documented in `references/report-template.md`.
The PDF report should include:
1. report header
2. verdict panel
3. subject-property summary table
4. Snapshot
5. What I like
6. What I do not like
7. Comp view
8. Underwriting / carry view
9. Risks and diligence items
10. Photo review
11. Public records
12. Source links
### Recipient-email gate
Before rendering or sending the PDF, the skill must know the target recipient email address(es).
If the prompt does **not** include target email(s):
- stop
- ask the user for the target email address(es)
- do **not** render or send the final PDF yet
If target email(s) are present:
- include them in the report payload
- render the PDF with the fixed template
- if a delivery workflow is available, use those same target email(s) for sending
The renderer enforces this gate and will fail if the payload has no recipient email list.
## Normalization / make-ready adjustment
Estimate a rough make-ready budget when condition is not turnkey. The goal is not contractor precision; the goal is apples-to-apples comparison.
@@ -257,7 +368,15 @@ Keep the answer concise but decision-grade:
6. Risks and diligence items
7. Verdict with fair value range and offer guidance
Also include:
- public-record / CAD evidence and links when available
- the path to the rendered PDF after generation
If the user did not provide recipient email(s), ask for them instead of finalizing the PDF workflow.
## Reuse notes
When condos are involved, same-building comps and HOA economics usually matter more than neighborhood averages.
For detailed heuristics and the reusable memo template, read `references/underwriting-rules.md`.
For detailed heuristics and the reusable memo template, read:
- `references/underwriting-rules.md`
- `references/report-template.md`

View File

@@ -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
View 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"
}
}
}
}

View 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"
}
}

View 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.

View 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" "$@"

View 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);
});

View 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(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/gi, "<")
.replace(/&gt;/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
};
}

View 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;
}

View 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);
});

View 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
);
});

View 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"]
}