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

View File

@@ -1,38 +1,44 @@
# property-assessor
Decision-grade residential property assessment skill for OpenClaw.
This skill is for evaluating a condo, townhouse, house, or similar residential property from an address or listing URL and ending with a practical recommendation such as `buy`, `pass`, or `only below X`.
Decision-grade residential property assessment skill for OpenClaw, with official public-record enrichment and fixed-template PDF report rendering.
## Overview
`property-assessor` is a workflow skill, not just a scraper. It is meant to:
`property-assessor` is for evaluating a condo, townhouse, house, or similar residential property from an address or listing URL and ending with a practical recommendation such as `buy`, `pass`, or `only below X`.
- normalize the target property across listing sources
- build a baseline fact set
The skill is intended to:
- normalize the property across listing sources
- review listing photos before making condition claims
- compare the property against nearby or same-building comps
- underwrite taxes, HOA, insurance, and realistic carrying costs
- identify risk drivers
- produce a concise but decision-grade verdict
- incorporate official public-record / appraisal-district context when available
- compare the property against comps and carrying costs
- produce a fixed-format PDF report, not just ad hoc chat prose
The skill is designed for real-world purchase decisions, especially when you need a fast read on whether a property is worth pursuing.
## Standalone helper usage
## Accepted inputs
This skill now ships with a small TypeScript helper package for two tasks:
The skill can start from any of:
- locating official public-record jurisdiction from an address
- rendering a fixed-template PDF report
- a street address
- a Zillow listing URL
- a HAR listing URL
- an address plus user constraints such as:
- investment only
- owner-occupant
- long-term rental
- short-term rental
- target distance/location requirements
From `skills/property-assessor/`:
Preferred starting point is the address when available, because it makes source reconciliation easier.
```bash
npm install
scripts/property-assessor --help
```
The wrapper script uses the skill-local Node dependencies under `node_modules/`.
## Commands
```bash
scripts/property-assessor locate-public-records --address "4141 Whiteley Dr, Corpus Christi, TX 78418"
scripts/property-assessor locate-public-records --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --parcel-id "14069438"
scripts/property-assessor locate-public-records --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --listing-geo-id "233290"
scripts/property-assessor render-report --input examples/report-payload.example.json --output /tmp/property-assessment.pdf
```
## Core workflow
@@ -40,16 +46,15 @@ Default operating sequence:
1. Normalize the address and property type.
2. Discover accessible listing and public-record sources for the same property.
3. Establish a baseline fact set from the best available source.
4. Cross-check the same property on other sources.
5. Review listing photos before making condition claims.
6. Pull same-building comps for condos or nearby comps for houses/townhomes.
7. Underwrite carry costs and risk drivers.
8. End with a specific recommendation and fair-value range.
3. Build a baseline fact set.
4. Review listing photos before making condition claims.
5. Pull same-building or nearby comps.
6. Underwrite carry costs and risk factors.
7. Render the final report as a fixed-template PDF.
## Source priority
Unless the user asks otherwise, preferred source order is:
Unless the user says otherwise, preferred listing/source order is:
1. Zillow
2. Redfin
@@ -57,11 +62,72 @@ Unless the user asks otherwise, preferred source order is:
4. HAR / Homes.com / brokerage mirrors
5. county or appraisal pages
Use high-quality mirrors to confirm facts, not to override a clearly better primary listing without reason.
Public-record / assessor data should be linked in the final result when available.
## Public-record enrichment
The skill should not rely on listing-site geo IDs as if they were assessor record identifiers.
Correct approach:
1. start from the street address
2. resolve the address to state/county/FIPS/GEOID
3. identify the official public-record jurisdiction
4. use parcel/APN/account identifiers when available
5. link the official jurisdiction page and any direct property page used
### `locate-public-records`
```bash
scripts/property-assessor locate-public-records --address "<street-address>"
```
Current behavior:
- uses the official Census geocoder
- returns matched address, county/state/FIPS, and block GEOID context
- for Texas, returns:
- Texas Comptroller county directory page
- appraisal district contact/site details
- tax assessor/collector contact/site details
Important rules:
- Zillow/Redfin/HAR geo IDs are hints only
- parcel/APN/account IDs are stronger search keys than listing geo IDs
- official jurisdiction pages should be linked in the final report
- if a direct property detail page is accessible, its data should be labeled as official public-record evidence
### Texas support
Texas is the first-class public-record path in this implementation.
For Texas addresses, the helper resolves:
- the official Census geocoder link
- the official Texas Comptroller county directory page
- the appraisal district website
- the tax assessor/collector website
That output should be used by the skill to:
- identify the correct CAD
- attempt address / parcel / account lookup on the CAD site
- capture official assessed values and exemptions when a public detail page is available
Recommended fields to capture from official records when accessible:
- account number
- owner name
- land value
- improvement value
- assessed total
- exemptions
- official property-detail URL
## Minimum data to capture
For the target property, capture as many of these as the sources support:
For the subject property, capture when available:
- address
- list price or last known list price
@@ -79,169 +145,107 @@ For the target property, capture as many of these as the sources support:
- subdivision or building name
- same-building or nearby active inventory
- listing photos and visible condition cues
- included appliances and obvious missing appliances
- flooring mix, especially carpet
- public-record jurisdiction and linked official source
- account / parcel / tax ID if confirmed
- official assessed values and exemptions if confirmed
## Photo-review requirement
## Photo-review rules
Photo review is mandatory when the listing sources expose photos.
Photo review is mandatory when photos are exposed by a listing source.
Do not make strong condition claims from structured text alone if photos are available.
### What counts as acceptable photo access
Preferred photo sources are:
- a scrollable all-photos page
- an expanded photo grid
- a photo page that exposes the full set
- a modal/lightbox only if the site does not provide a better all-photos path
Do not treat these as full photo review by themselves:
- the listing hero image
- a collage preview
- a photo count without image access
- listing shell text that mentions photos
### What to inspect in the photos
At minimum, evaluate:
- overall finish level: dated, average, lightly updated, fully updated
- kitchen condition: cabinets, counters, backsplash, appliances
- bathroom condition: vanity, tile, surrounds, fixtures
- flooring: tile, vinyl, laminate, hardwood, carpet
- obvious make-ready issues: paint, trim, wear, damage, mismatched finishes
- visible missing items: refrigerator, washer/dryer, range hood, dishwasher
- signs of deferred maintenance or water intrusion
- exterior and common-area condition where visible
- waterfront-facing elements, balconies, decks, sliders, and windows when relevant
### If photo review is incomplete
If the agent cannot access enough photos to make a credible read:
- say so explicitly
- lower confidence
- avoid strong turnkey claims
- continue the broader underwriting work, but mark condition as limited-confidence
## Zillow and HAR integration
This skill now expects the dedicated `web-automation` extractors first instead of fragile ad hoc gallery automation.
### Zillow first
Run:
```bash
cd ~/.openclaw/workspace/skills/web-automation/scripts
node zillow-photos.js "<zillow-listing-url>"
```
Successful Zillow photo access means one of these happened:
- the `See all photos` / `See all X photos` path opened a usable all-photos experience, or
- the rendered Zillow listing shell already exposed the full direct Zillow image set and the extracted count matches the announced count
Important rule:
- when the extractor returns `imageUrls`, that returned set is the photo-review set
For smaller listings, review the full extracted set when practical. For a 20-30 photo listing, that usually means all photos.
### HAR fallback
If Zillow does not expose a reliable image set, use HAR next:
```bash
cd ~/.openclaw/workspace/skills/web-automation/scripts
node har-photos.js "<har-listing-url>"
```
Successful HAR photo access means:
- the HAR listing opened
- `Show all photos` / `View all photos` exposed the photo page
- direct `pics.harstatic.com` image URLs were extracted
As with Zillow, the returned `imageUrls` are the review set for condition analysis.
### Practical photo-source order
Use this action order:
Preferred photo-access order:
1. Zillow extractor
2. HAR extractor
3. Realtor.com photo page
4. brokerage mirror or other accessible listing mirror
Do not stop after the first failed source if a fallback source can still expose the photos.
Use the dedicated `web-automation` extractors first:
## Approval-safe execution
For chat-driven property assessments, prefer file-based commands under:
```text
~/.openclaw/workspace/skills/web-automation/scripts
```bash
cd ~/.openclaw/workspace/skills/web-automation/scripts
node zillow-photos.js "<zillow-listing-url>"
node har-photos.js "<har-listing-url>"
```
Good command shape:
When those extractors return `imageUrls`, that returned set is the photo-review set.
## Approval-safe command shape
For chat-driven runs, prefer file-based commands.
Good:
- `node check-install.js`
- `node zillow-photos.js "<url>"`
- `node har-photos.js "<url>"`
- `scripts/property-assessor locate-public-records --address "..."`
- `scripts/property-assessor render-report --input ... --output ...`
Avoid when possible:
- `node -e "..."`
- `node --input-type=module -e "..."`
Why this matters:
## PDF report template
- OpenClaw exec approvals are easier to allowlist for stable file paths
- inline interpreter eval is more likely to trigger approval friction
- the installed approval allowlist is typically already scoped to the `*.js` files under the `web-automation/scripts` directory
The final deliverable should be a fixed-template PDF, not a one-off layout.
## Make-ready normalization
Template reference:
Condition should be translated into a rough make-ready range so pricing and comp comparisons stay realistic.
- `skills/property-assessor/references/report-template.md`
Use simple buckets:
Current renderer:
- light make-ready
- paint, fixtures, minor hardware, small patching
- medium make-ready
- partial flooring replacement, appliance replacement, bathroom refresh
- heavy make-ready
- significant kitchen/bath work, widespread flooring, visible deferred maintenance
```bash
scripts/property-assessor render-report --input "<report-payload-json>" --output "<output-pdf>"
```
Call out carpet separately when present, especially in bedrooms, stairs, or living areas.
The fixed template includes:
## Underwriting expectations
1. Report header
2. Verdict panel
3. Subject-property summary table
4. Snapshot
5. What I Like
6. What I Do Not Like
7. Comp View
8. Underwriting / Carry View
9. Risks and Diligence Items
10. Photo Review
11. Public Records
12. Source Links
13. Notes page
The final assessment should show a simple carrying-cost view including:
### Recipient email gate
- principal and interest if available
- taxes per month
- HOA per month if applicable
- insurance estimate or explicit uncertainty
- realistic carry range after maintenance, vacancy, and property-specific risk
The report must not be rendered or sent unless target recipient email address(es) are known.
Strong caution flags include:
If the prompt does not include recipient email(s), the skill should:
- high HOA relative to price or expected rent
- older waterfront or coastal exposure
- unknown reserve or assessment history for condos
- many active units in the same building or micro-area
- stale days on market with weak price action
- no clear rent support
- stop
- ask for target recipient email address(es)
- not finalize the PDF workflow yet
The renderer enforces this. If `recipientEmails` is missing or empty, it fails with:
`Missing target email. Stop and ask the user for target email address(es) before generating or sending the property assessment PDF.`
## Example payload
Sample payload:
- `skills/property-assessor/examples/report-payload.example.json`
This is the easiest way to test the renderer without building a report payload from scratch.
## Output contract
The final answer should stay concise but decision-grade.
The assessment itself should remain concise but decision-grade.
Recommended structure:
Recommended narrative structure:
1. Snapshot
2. What I like
@@ -251,76 +255,77 @@ Recommended structure:
6. Risks and diligence items
7. Verdict with fair-value range and offer guidance
The output must explicitly include:
It must also explicitly include:
- `Photo source attempts: ...`
- `Photo review: completed via <source>`
or `Photo review: not completed`
- `Photo review: completed via <source>` or `Photo review: not completed`
- public-record / CAD evidence and links when available
If photo review was completed:
## Validation flow
- summarize the condition read from the photos
- mention obvious finish level, flooring, appliance presence, and make-ready signals
If not completed:
- mark condition confidence as limited
- explain why photo access was incomplete
## Example validation flow
Use these commands for a known-good regression check.
### Verify extractor prerequisites
### 1. Install the helper package locally
```bash
cd ~/.openclaw/workspace/skills/web-automation/scripts
node check-install.js
npm run test:photos
cd ~/.openclaw/workspace/skills/property-assessor
npm install
npm test
```
### Zillow regression check
### 2. Run public-record lookup
```bash
cd ~/.openclaw/workspace/skills/web-automation/scripts
node zillow-photos.js "https://www.zillow.com/homedetails/4141-Whiteley-Dr-Corpus-Christi-TX-78418/2103723704_zpid/"
cd ~/.openclaw/workspace/skills/property-assessor
scripts/property-assessor locate-public-records --address "4141 Whiteley Dr, Corpus Christi, TX 78418"
```
Expected shape:
- `complete: true`
- `expectedPhotoCount: 29`
- `photoCount: 29`
- state/county/FIPS/GEOID present
- official Census geocoder link present
- for Texas: Comptroller county directory link present
- for Texas: appraisal district and tax assessor/collector contacts present
### HAR regression check
### 3. Run PDF render with the sample payload
```bash
cd ~/.openclaw/workspace/skills/web-automation/scripts
node har-photos.js "https://www.har.com/homedetail/4141-whiteley-dr-corpus-christi-tx-78418/14069438"
cd ~/.openclaw/workspace/skills/property-assessor
scripts/property-assessor render-report --input examples/report-payload.example.json --output /tmp/property-assessment.pdf
```
Expected shape:
Expected result:
- `complete: true`
- `expectedPhotoCount: 29`
- `photoCount: 29`
- JSON success payload with `outputPath`
- a non-empty PDF written to `/tmp/property-assessment.pdf`
### Skill-level validation
### 4. Verify the email gate
When testing `property-assessor` itself, confirm the resulting assessment:
Run the renderer with a payload that omits `recipientEmails`.
- attempts Zillow first
- falls back to HAR if needed
- references actual photo access, not just listing text
- includes the required `Photo source attempts` line
- includes the required `Photo review` line
- makes condition claims consistent with the reviewed image set
Expected result:
- non-zero exit
- explicit message telling the operator to stop and ask for target recipient email(s)
### 5. Verify the end-to-end skill behavior
When testing `property-assessor` itself, confirm the assessment:
- starts from the address when available
- uses Zillow first for photo extraction, HAR as fallback
- uses official public-record jurisdiction links when available
- does not treat listing geo IDs as assessor keys
- asks for recipient email(s) if they were not provided
- renders the final report through the fixed PDF template once recipient email(s) are known
## Related files
- installed skill instructions:
- `~/.openclaw/workspace/skills/property-assessor/SKILL.md`
- repo skill instructions:
- skill instructions:
- `skills/property-assessor/SKILL.md`
- photo extractor docs:
- underwriting heuristics:
- `skills/property-assessor/references/underwriting-rules.md`
- PDF template rules:
- `skills/property-assessor/references/report-template.md`
- sample report payload:
- `skills/property-assessor/examples/report-payload.example.json`
- photo extraction docs:
- `docs/web-automation.md`

3
skills/property-assessor/.gitignore vendored Normal file
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"]
}