From e6d987d72544b276a61859f60659d452b4f25683 Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Fri, 27 Mar 2026 22:23:58 -0500 Subject: [PATCH] Port property assessor helpers to TypeScript --- docs/property-assessor.md | 407 ++++----- skills/property-assessor/.gitignore | 3 + skills/property-assessor/SKILL.md | 121 ++- .../examples/report-payload.example.json | 79 ++ skills/property-assessor/package-lock.json | 791 ++++++++++++++++++ skills/property-assessor/package.json | 21 + .../references/report-template.md | 56 ++ .../scripts/property-assessor | 13 + skills/property-assessor/src/cli.ts | 63 ++ .../property-assessor/src/public-records.ts | 292 +++++++ skills/property-assessor/src/report-pdf.ts | 343 ++++++++ .../tests/public-records.test.ts | 82 ++ .../tests/report-pdf.test.ts | 69 ++ skills/property-assessor/tsconfig.json | 17 + 14 files changed, 2155 insertions(+), 202 deletions(-) create mode 100644 skills/property-assessor/.gitignore create mode 100644 skills/property-assessor/examples/report-payload.example.json create mode 100644 skills/property-assessor/package-lock.json create mode 100644 skills/property-assessor/package.json create mode 100644 skills/property-assessor/references/report-template.md create mode 100755 skills/property-assessor/scripts/property-assessor create mode 100644 skills/property-assessor/src/cli.ts create mode 100644 skills/property-assessor/src/public-records.ts create mode 100644 skills/property-assessor/src/report-pdf.ts create mode 100644 skills/property-assessor/tests/public-records.test.ts create mode 100644 skills/property-assessor/tests/report-pdf.test.ts create mode 100644 skills/property-assessor/tsconfig.json diff --git a/docs/property-assessor.md b/docs/property-assessor.md index fb1fb14..9793400 100644 --- a/docs/property-assessor.md +++ b/docs/property-assessor.md @@ -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 "" +``` + +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 "" -``` - -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 "" -``` - -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 "" +node har-photos.js "" ``` -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 ""` - `node har-photos.js ""` +- `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 "" --output "" +``` -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 ` - or `Photo review: not completed` +- `Photo review: completed via ` 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` diff --git a/skills/property-assessor/.gitignore b/skills/property-assessor/.gitignore new file mode 100644 index 0000000..6d0301a --- /dev/null +++ b/skills/property-assessor/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.venv/ diff --git a/skills/property-assessor/SKILL.md b/skills/property-assessor/SKILL.md index 9ecfd1c..ae7b8e9 100644 --- a/skills/property-assessor/SKILL.md +++ b/skills/property-assessor/SKILL.md @@ -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 "" +``` + +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 "" --output "" +``` + +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` diff --git a/skills/property-assessor/examples/report-payload.example.json b/skills/property-assessor/examples/report-payload.example.json new file mode 100644 index 0000000..d9823c4 --- /dev/null +++ b/skills/property-assessor/examples/report-payload.example.json @@ -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" + } + ] +} diff --git a/skills/property-assessor/package-lock.json b/skills/property-assessor/package-lock.json new file mode 100644 index 0000000..28a94a2 --- /dev/null +++ b/skills/property-assessor/package-lock.json @@ -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" + } + } + } +} diff --git a/skills/property-assessor/package.json b/skills/property-assessor/package.json new file mode 100644 index 0000000..936914e --- /dev/null +++ b/skills/property-assessor/package.json @@ -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" + } +} diff --git a/skills/property-assessor/references/report-template.md b/skills/property-assessor/references/report-template.md new file mode 100644 index 0000000..d147a52 --- /dev/null +++ b/skills/property-assessor/references/report-template.md @@ -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. diff --git a/skills/property-assessor/scripts/property-assessor b/skills/property-assessor/scripts/property-assessor new file mode 100755 index 0000000..ea65c01 --- /dev/null +++ b/skills/property-assessor/scripts/property-assessor @@ -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" "$@" diff --git a/skills/property-assessor/src/cli.ts b/skills/property-assessor/src/cli.ts new file mode 100644 index 0000000..ec0e834 --- /dev/null +++ b/skills/property-assessor/src/cli.ts @@ -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 "
" [--parcel-id ""] [--listing-geo-id ""] [--listing-source-url ""] + render-report --input "" --output "" +`); +} + +async function main(): Promise { + 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); +}); diff --git a/skills/property-assessor/src/public-records.ts b/skills/property-assessor/src/public-records.ts new file mode 100644 index 0000000..e7650a3 --- /dev/null +++ b/skills/property-assessor/src/public-records.ts @@ -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 | null; + taxAssessorCollector: Record | null; + lookupRecommendations: string[]; + sourceIdentifierHints: Record; +} + +interface FetchLike { + (url: string): Promise; +} + +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(//gi, "\n") + .replace(/<[^>]+>/g, ""); + output = output + .replace(/ /gi, " ") + .replace(/&/gi, "&") + .replace(/"/gi, '"') + .replace(/'/g, "'") + .replace(/</gi, "<") + .replace(/>/gi, ">"); + output = collapseWhitespace(output.replace(/\n/g, ", ")); + output = output.replace(/\s*,\s*/g, ", ").replace(/(,\s*){2,}/g, ", "); + return output.replace(/^,\s*|\s*,\s*$/g, ""); +} + +function extractAnchorHref(fragment: string): string | null { + const match = fragment.match(/]+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 { + const html = await fetchText(TEXAS_COUNTY_DIRECTORY_URL); + const countyNorm = normalizeCountyName(countyName); + const matches = html.matchAll(/\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 { + const result: Record = {}; + + const lastUpdated = sectionHtml.match( + /

\s*Last Updated:\s*([^<]+)<\/p>/i + ); + if (lastUpdated) { + result.lastUpdated = collapseWhitespace(lastUpdated[1]); + } + + const lead = sectionHtml.match(/

\s*([^:<]+):\s*([^<]+)<\/h4>/i); + if (lead) { + result[lead[1].trim()] = collapseWhitespace(lead[2]); + } + + const infoBlock = sectionHtml.match(/

\s*[^<]+<\/h4>\s*

(.*?)<\/p>/is); + if (infoBlock) { + for (const match of infoBlock[1].matchAll( + /\s*([^:<]+):\s*<\/strong>\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(`

\\s*${heading}\\s*<\\/h4>\\s*

(.*?)<\\/p>`, "is") + ); + if (match) { + result[key] = stripHtml(match[1]); + } + } + + return result; +} + +async function fetchTexasCountyOffices( + countyName: string, + fetchText: FetchLike +): Promise<{ + directoryPage: string; + appraisalDistrict: Record; + taxAssessorCollector: Record | null; +}> { + const pageUrl = await findTexasCountyHref(countyName, fetchText); + const html = await fetchText(pageUrl); + const appraisalMatch = html.match( + /

\s*Appraisal District\s*<\/h3>(.*?)(?=

\s*Tax Assessor\/Collector\s*<\/h3>)/is + ); + const taxMatch = html.match(/

\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 { + 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 | null = null; + let taxAssessorCollector: Record | 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 = {}; + 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 + }; +} diff --git a/skills/property-assessor/src/report-pdf.ts b/skills/property-assessor/src/report-pdf.ts new file mode 100644 index 0000000..0559bde --- /dev/null +++ b/skills/property-assessor/src/report-pdf.ts @@ -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; + verdict?: Record; + snapshot?: unknown; + whatILike?: unknown; + whatIDontLike?: unknown; + compView?: unknown; + carryView?: unknown; + risksAndDiligence?: unknown; + photoReview?: Record; + publicRecords?: Record; + 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) + .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 | 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>) { + 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 { + 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((resolve, reject) => { + stream.on("finish", () => resolve()); + stream.on("error", reject); + }); + + return outputPath; +} + +export async function loadReportPayload(inputPath: string): Promise { + return JSON.parse(await fs.promises.readFile(inputPath, "utf8")) as ReportPayload; +} diff --git a/skills/property-assessor/tests/public-records.test.ts b/skills/property-assessor/tests/public-records.test.ts new file mode 100644 index 0000000..41f1373 --- /dev/null +++ b/skills/property-assessor/tests/public-records.test.ts @@ -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 = ` + +`; + +const countyPageHtml = ` +
+

Appraisal District

+

Last Updated: 08/13/2025

+

Chief Appraiser: Debra Morin, Interim

+

+ Phone: 361-881-9978
+ Email: info@nuecescad.net
+ Website: www.ncadistrict.com +

+

Mailing Address

+

201 N. Chaparral St.
Corpus Christi, TX 78401-2503

+
+
+

Tax Assessor/Collector

+

Last Updated: 02/18/2025

+

Tax Assessor-Collector: Kevin Kieschnick

+

+ Phone: 361-888-0307
+ Email: nueces.tax@nuecesco.com
+ Website: www.nuecesco.com +

+

Street Address

+

901 Leopard St., Room 301
Corpus Christi, Texas 78401-3602

+
+`; + +const fakeFetchText = async (url: string): Promise => { + 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); +}); diff --git a/skills/property-assessor/tests/report-pdf.test.ts b/skills/property-assessor/tests/report-pdf.test.ts new file mode 100644 index 0000000..f9519a9 --- /dev/null +++ b/skills/property-assessor/tests/report-pdf.test.ts @@ -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 + ); +}); diff --git a/skills/property-assessor/tsconfig.json b/skills/property-assessor/tsconfig.json new file mode 100644 index 0000000..4f5d71e --- /dev/null +++ b/skills/property-assessor/tsconfig.json @@ -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"] +}