← back to Rentv V1
Add drop-in contractor lookup package (contrib/contractors, TK-10488)
64f7dfef6f028dd306fac4e3d87b2497a1a7dfbb · 2026-08-12 10:14:18 -0700 · Steve Abrams
Self-contained, new files only — no edits to any existing/live RENTV file.
- router.js: Express router proxying the shared usre CSLB contractor API
(GET /api/contractors/match?mode=deal&county=&city=) and re-serving a slim,
text-only shape at GET /api/contractors-for-market; flatten+dedupe, 5m cache,
fail-soft (never 500s a story).
- widget.html: zero-dependency text-only 'Licensed contractors in {market}'
block (name+trade+phone); auto-init from data-attrs or programmatic; hides
itself on empty/error; no link-out, no re-hosted assets.
- README.md: 2-line mount + include steps for claude-rentv, env config, smoke
test. Publish/deploy gated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
A .gitignoreA contrib/contractors/README.mdA contrib/contractors/router.jsA contrib/contractors/widget.html
Diff
commit 64f7dfef6f028dd306fac4e3d87b2497a1a7dfbb
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Aug 12 10:14:18 2026 -0700
Add drop-in contractor lookup package (contrib/contractors, TK-10488)
Self-contained, new files only — no edits to any existing/live RENTV file.
- router.js: Express router proxying the shared usre CSLB contractor API
(GET /api/contractors/match?mode=deal&county=&city=) and re-serving a slim,
text-only shape at GET /api/contractors-for-market; flatten+dedupe, 5m cache,
fail-soft (never 500s a story).
- widget.html: zero-dependency text-only 'Licensed contractors in {market}'
block (name+trade+phone); auto-init from data-attrs or programmatic; hides
itself on empty/error; no link-out, no re-hosted assets.
- README.md: 2-line mount + include steps for claude-rentv, env config, smoke
test. Publish/deploy gated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
.gitignore | 11 +++
contrib/contractors/README.md | 151 +++++++++++++++++++++++++++++++
contrib/contractors/router.js | 194 ++++++++++++++++++++++++++++++++++++++++
contrib/contractors/widget.html | 128 ++++++++++++++++++++++++++
4 files changed, 484 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..483965d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+node_modules/
+.env
+.env.*
+tmp/
+*.log
+*.err
+.DS_Store
+dist/
+build/
+.next/
+data/
diff --git a/contrib/contractors/README.md b/contrib/contractors/README.md
new file mode 100644
index 0000000..c756ba2
--- /dev/null
+++ b/contrib/contractors/README.md
@@ -0,0 +1,151 @@
+# RENTV contrib — Licensed-contractor market lookup (TK-10488)
+
+A **drop-in** package that adds a "Licensed contractors in {market}" block to a
+CRE deal news story on RENTV, sourced from the **shared usre CSLB contractor
+registry** — with **zero edits to any existing RENTV file** and **no new npm
+dependencies**.
+
+Built by `claude-rentv-contractors` under stand-down (M-00122): these are
+**new files only**. RENTV (`claude-rentv`) owns the wire-in and any deploy.
+
+```
+contrib/contractors/
+├── router.js Express router (proxy → usre contractor API)
+├── widget.html self-contained text-only widget (markup + <script>)
+└── README.md this file
+```
+
+---
+
+## What it does
+
+- **`router.js`** exposes **`GET /api/contractors-for-market?county=&city=`**
+ and proxies the shared usre API:
+ `GET {CONTRACTORS_API_BASE}/api/contractors/match?mode=deal&county=&city=`
+ → `{ matches: { <classCode>: [ ... ] } }`.
+ It flattens + dedupes (a GC appears under several class codes) into a slim,
+ **text-only** shape and caches responses in-memory (5 min TTL).
+- **`widget.html`** renders that list on a story — **name + trade + phone**,
+ plus city + license number. **No links out, no re-hosted assets** (honors the
+ RENTV no-rentv.com-link-out rule). Fails soft: if the sidecar is down or the
+ market has no matches, the widget **hides itself** so the story stays clean.
+
+---
+
+## Mount the router (RENTV `server.js`)
+
+RENTV's `server.js` is Express/CommonJS with `app.use(...)`. Add **one require +
+one mount line** — nothing else changes:
+
+```js
+// near the other requires
+const contractorsRouter = require('./contrib/contractors/router');
+
+// after express.json() is set up, alongside the other app.use / app.get routes:
+app.use('/contrib/contractors', contractorsRouter); // module.exports IS a ready router
+// (or, to pass an explicit upstream base:)
+// app.use('/contrib/contractors', require('./contrib/contractors/router')({ apiBase: 'http://localhost:9913' }));
+```
+
+That mounts:
+
+| Route | Purpose |
+|-------|---------|
+| `GET /contrib/contractors/health` | self-describe / confirm wired |
+| `GET /contrib/contractors/api/contractors-for-market?county=&city=[&mode=deal][&limit=6]` | the lookup |
+
+> `module.exports` is a router-**factory**; `module.exports` is *also* directly a
+> ready `express.Router()` (factory `.call`-safe) — the `require('...')` form
+> above works as-is. Use the `({ apiBase })` call form only if you need to
+> override the upstream base per-mount.
+
+### Config (env, all optional)
+
+| Env var | Default | Meaning |
+|---------|---------|---------|
+| `CONTRACTORS_API_BASE` | `http://localhost:9913` | usre contractor API base |
+| `CONTRACTORS_API_AUTH` | *(none)* | `user:pass` if the usre API needs Basic Auth |
+| `CONTRACTORS_API_TIMEOUT_MS` | `6000` | upstream fetch timeout |
+| `CONTRACTORS_DEFAULT_LIMIT` | `6` | contractors returned when `limit` omitted |
+| `CONTRACTORS_CACHE_TTL_MS` | `300000` | in-memory cache TTL |
+
+> The usre API is behind Basic Auth (CRCP pattern). On the same host / loopback
+> it may not require creds; if it does, set `CONTRACTORS_API_AUTH="admin:…"`.
+> **Heads-up (2026-08-12):** the process currently listening on `:9913`
+> (pid observed 28661) is a **stale build predating the `/api/contractors`
+> route** and returns 404 for it. The route exists in the usre source
+> (`nationalrealestate/src/server/contractors.ts`); usre will need a restart
+> (its own team's call, not ours) before this returns live data. Until then the
+> widget fails soft and hides. **No action needed from RENTV to be safe.**
+
+---
+
+## Include the widget (a CRE deal story template)
+
+**Option A — data-attribute auto-init.** Drop the container where you want the
+block, then include the widget once (its `<script>` self-inits on DOM ready):
+
+```html
+<div class="rentv-contractors"
+ data-county="Los Angeles"
+ data-city="Los Angeles"
+ data-limit="6"></div>
+
+<!-- include once per page (e.g. server-side partial include of widget.html,
+ or paste its <style>+<script> into the layout) -->
+```
+
+If you serve `widget.html` as a static partial, the simplest wire is to
+copy its `<style>` + `<script>` into the story layout, or add a static route:
+
+```js
+// optional: serve the raw widget partial (new file, still no existing-file edits)
+app.get('/contrib/contractors/widget.html', (_q, r) =>
+ r.sendFile(require('path').join(__dirname, 'contrib/contractors/widget.html')));
+```
+
+**Option B — programmatic.** After the widget script is loaded:
+
+```html
+<div id="deal-contractors"></div>
+<script>
+ RentvContractors.render(document.getElementById('deal-contractors'), {
+ county: 'Los Angeles',
+ city: 'Los Angeles',
+ limit: 6,
+ // endpoint: '/contrib/contractors' // default; override if mounted elsewhere
+ });
+</script>
+```
+
+The widget reads `county`/`city` straight off the deal record you already show
+on the story — pass whatever the article's parcel/deal object carries.
+
+---
+
+## Smoke test (after mount, before publish)
+
+```sh
+# router is wired?
+curl -s localhost:9704/contrib/contractors/health | jq .
+
+# lookup (returns {market,count,contractors:[{name,trade,phone,city,license_no}],attribution}):
+curl -s 'localhost:9704/contrib/contractors/api/contractors-for-market?county=Los%20Angeles&city=Los%20Angeles&limit=6' | jq .
+```
+
+(Use whatever port/host RENTV runs on — `:9704` locally.)
+
+---
+
+## Rails honored
+
+- **No edits to existing RENTV files** — everything is new under
+ `contrib/contractors/`. Mounting is 2 lines RENTV adds when ready.
+- **No new dependencies** — Node built-in `fetch` + `express` (already present).
+- **Text/attribution only** — name, trade, phone, city, license #. No link-out,
+ no re-hosted assets.
+- **Fail-soft** — a down/slow/stale upstream never 500s a news story; the widget
+ hides itself.
+- **Publish/deploy is gated** — this package ships wire-ready; the go-live is
+ RENTV's + Steve's call. See the go-live memo in
+ `~/.claude/yolo-queue/pending-approval/` (TK-10488).
diff --git a/contrib/contractors/router.js b/contrib/contractors/router.js
new file mode 100644
index 0000000..27fd6cf
--- /dev/null
+++ b/contrib/contractors/router.js
@@ -0,0 +1,194 @@
+'use strict';
+// ============================================================================
+// RENTV contrib — Licensed-contractor market lookup router (TK-10488)
+// ----------------------------------------------------------------------------
+// A SELF-CONTAINED, DROP-IN Express router that RENTV can mount to expose
+// "licensed contractors for a market" on a CRE deal news story, without RENTV
+// having to know anything about the shared usre CSLB contractor database.
+//
+// It proxies the shared usre (nationalrealestate) contractor API:
+// GET {CONTRACTORS_API_BASE}/api/contractors/match?mode=deal&county=&city=
+// -> { mode, criteria, cap_per_group, matches: { <classCode>: [ ... ] } }
+//
+// and re-serves a SLIM, text-only shape at:
+// GET /api/contractors-for-market?county=&city=[&mode=deal][&limit=6]
+//
+// Zero new dependencies: uses Node's built-in global fetch (Node 18+) and a
+// small in-memory TTL cache. Nothing here writes to any database, touches any
+// existing RENTV file, or re-hosts any external asset.
+//
+// Owned by: claude-rentv-contractors (contrib author). RENTV (claude-rentv)
+// mounts it when ready — see README.md. Gated for any publish/deploy.
+// ============================================================================
+
+const express = require('express');
+
+// --- Config (all env-overridable; safe defaults) ----------------------------
+const API_BASE = (
+ process.env.CONTRACTORS_API_BASE || 'http://localhost:9913'
+).replace(/\/+$/, ''); // trim trailing slash
+
+// The usre API is behind Basic Auth (CRCP pattern). RENTV can supply creds via
+// CONTRACTORS_API_AUTH="user:pass"; if unset we send none and let the upstream
+// decide (loopback / same-host deployments may not require it).
+const API_AUTH = process.env.CONTRACTORS_API_AUTH || '';
+
+// Upstream request timeout (ms) and per-trade result cap we surface to callers.
+const API_TIMEOUT_MS = Number(process.env.CONTRACTORS_API_TIMEOUT_MS || 6000);
+const DEFAULT_LIMIT = Number(process.env.CONTRACTORS_DEFAULT_LIMIT || 6);
+const MAX_LIMIT = 24;
+
+// Short in-memory cache so a hot news story doesn't hammer the upstream.
+const CACHE_TTL_MS = Number(process.env.CONTRACTORS_CACHE_TTL_MS || 5 * 60 * 1000);
+const _cache = new Map(); // key -> { at, data }
+
+function cacheGet(key) {
+ const hit = _cache.get(key);
+ if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.data;
+ if (hit) _cache.delete(key);
+ return null;
+}
+function cacheSet(key, data) {
+ _cache.set(key, { at: Date.now(), data });
+ // bound the cache so a spidered site can't grow it without limit
+ if (_cache.size > 500) {
+ const oldest = [..._cache.entries()].sort((a, b) => a[1].at - b[1].at)[0];
+ if (oldest) _cache.delete(oldest[0]);
+ }
+}
+
+// --- Upstream fetch with timeout -------------------------------------------
+async function fetchUpstream(url) {
+ const ctrl = new AbortController();
+ const t = setTimeout(() => ctrl.abort(), API_TIMEOUT_MS);
+ try {
+ const headers = { accept: 'application/json' };
+ if (API_AUTH) {
+ headers.authorization = 'Basic ' + Buffer.from(API_AUTH).toString('base64');
+ }
+ const r = await fetch(url, { headers, signal: ctrl.signal });
+ if (!r.ok) {
+ const body = await r.text().catch(() => '');
+ const err = new Error(`upstream ${r.status}`);
+ err.status = r.status;
+ err.body = body.slice(0, 300);
+ throw err;
+ }
+ return await r.json();
+ } finally {
+ clearTimeout(t);
+ }
+}
+
+// --- Flatten the upstream {matches:{code:[...]}} into a slim, deduped list ---
+// Text/attribution only: name + trade + phone + city/county + license. No links
+// out to any external site, no re-hosted assets.
+function flattenMatches(payload, limit) {
+ const matches = payload && payload.matches && typeof payload.matches === 'object'
+ ? payload.matches : {};
+ const seen = new Set(); // dedupe by license_no (a GC can appear under multiple class codes)
+ const out = [];
+ for (const code of Object.keys(matches)) {
+ const list = Array.isArray(matches[code]) ? matches[code] : [];
+ for (const c of list) {
+ if (!c || !c.business_name) continue;
+ const key = c.license_no || `${c.business_name}|${c.city || ''}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+
+ // Prefer the human-readable trade title for the matched class code; fall
+ // back to the contractor's primary_class or the raw code.
+ let trade = null;
+ const titles = Array.isArray(c.classification_titles) ? c.classification_titles : [];
+ const hit = titles.find((x) => x && x.code === code && x.title);
+ if (hit) trade = hit.title;
+ else if (titles.length && titles[0].title) trade = titles[0].title;
+ else trade = c.primary_class || code;
+
+ out.push({
+ name: String(c.business_name),
+ trade: trade ? String(trade) : null,
+ phone: c.phone ? String(c.phone) : null,
+ city: c.city ? String(c.city) : null,
+ county: c.county ? String(c.county) : null,
+ license_no: c.license_no ? String(c.license_no) : null,
+ license_status: c.license_status ? String(c.license_status) : null,
+ });
+ if (out.length >= limit) return out;
+ }
+ }
+ return out;
+}
+
+// --- Router factory ---------------------------------------------------------
+// Returns an express.Router() ready to mount. All routes are relative, so the
+// host chooses the mount path (README recommends app.use('/contrib/contractors', ...)).
+function createContractorsRouter(opts = {}) {
+ const router = express.Router();
+ const base = (opts.apiBase || API_BASE).replace(/\/+$/, '');
+
+ // Health / self-describe — lets RENTV confirm the drop-in is wired.
+ router.get('/health', (_req, res) => {
+ res.json({ ok: true, contrib: 'contractors', upstream: base, cache_ttl_ms: CACHE_TTL_MS });
+ });
+
+ // GET /api/contractors-for-market?county=&city=[&mode=deal][&limit=6]
+ router.get('/api/contractors-for-market', async (req, res) => {
+ const county = req.query.county ? String(req.query.county).trim().slice(0, 80) : '';
+ const city = req.query.city ? String(req.query.city).trim().slice(0, 80) : '';
+ const mode = (req.query.mode ? String(req.query.mode) : 'deal').toLowerCase() === 'home'
+ ? 'home' : 'deal';
+ let limit = Number(req.query.limit || DEFAULT_LIMIT);
+ if (!Number.isFinite(limit) || limit < 1) limit = DEFAULT_LIMIT;
+ if (limit > MAX_LIMIT) limit = MAX_LIMIT;
+
+ if (!county && !city) {
+ return res.status(400).json({ error: 'county or city is required' });
+ }
+
+ const qs = new URLSearchParams({ mode });
+ if (county) qs.set('county', county);
+ if (city) qs.set('city', city);
+ const url = `${base}/api/contractors/match?${qs.toString()}`;
+ const cacheKey = `${url}|${limit}`;
+
+ const cached = cacheGet(cacheKey);
+ if (cached) return res.json({ ...cached, cached: true });
+
+ try {
+ const payload = await fetchUpstream(url);
+ const contractors = flattenMatches(payload, limit);
+ const result = {
+ market: {
+ city: (payload.criteria && payload.criteria.city) || city || null,
+ county: (payload.criteria && payload.criteria.county) || county || null,
+ mode,
+ },
+ count: contractors.length,
+ contractors,
+ attribution: 'CA CSLB licensed contractors via shared usre registry',
+ source: 'usre-contractors',
+ };
+ cacheSet(cacheKey, result);
+ res.json(result);
+ } catch (e) {
+ // Fail soft: the widget treats an error/empty list as "none to show" and
+ // hides itself — a news story must never 500 because the sidecar is down.
+ const status = e && e.status === 401 ? 502 : (e && e.status) || 502;
+ res.status(status).json({
+ error: 'contractor lookup unavailable',
+ detail: String((e && e.message) || e),
+ market: { city: city || null, county: county || null, mode },
+ count: 0,
+ contractors: [],
+ });
+ }
+ });
+
+ return router;
+}
+
+module.exports = createContractorsRouter;
+module.exports.createContractorsRouter = createContractorsRouter;
+// Convenience: a ready-to-mount default instance using env config.
+module.exports.router = createContractorsRouter();
diff --git a/contrib/contractors/widget.html b/contrib/contractors/widget.html
new file mode 100644
index 0000000..d96d85e
--- /dev/null
+++ b/contrib/contractors/widget.html
@@ -0,0 +1,128 @@
+<!-- =========================================================================
+ RENTV contrib — "Licensed contractors in {market}" widget (TK-10488)
+ -------------------------------------------------------------------------
+ A SELF-CONTAINED, drop-in widget for a CRE deal news story. Paste this
+ whole block into a story template (or include the <script> from a partial).
+ It calls the contrib router's /api/contractors-for-market endpoint and
+ renders a TEXT-ONLY list (name + trade + phone). No external links, no
+ re-hosted assets, honoring the RENTV no-rentv.com-link-out rule.
+
+ USAGE (two ways):
+
+ 1) Auto-init from data attributes — drop the markup on the page:
+ <div class="rentv-contractors"
+ data-county="Los Angeles" data-city="Los Angeles"
+ data-limit="6"></div>
+ ...then include this file once (or its <script>) anywhere after it.
+
+ 2) Programmatic — call the global:
+ RentvContractors.render(document.getElementById('box'),
+ { county: 'Los Angeles', city: 'Los Angeles', limit: 6 });
+
+ The endpoint base defaults to same-origin '/contrib/contractors'; override
+ with data-endpoint="..." or the { endpoint } option.
+ ========================================================================= -->
+<style>
+ .rentv-contractors { font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color: #1a1a1a; }
+ .rentv-contractors__title { font-size: 15px; font-weight: 700; margin: 0 0 .5em; letter-spacing: .01em; }
+ .rentv-contractors__list { list-style: none; margin: 0; padding: 0; }
+ .rentv-contractors__item { padding: .55em 0; border-top: 1px solid #e6e6e6; }
+ .rentv-contractors__item:first-child { border-top: none; }
+ .rentv-contractors__name { font-weight: 600; }
+ .rentv-contractors__trade { color: #555; }
+ .rentv-contractors__phone { color: #333; }
+ .rentv-contractors__meta { color: #888; font-size: 12px; }
+ .rentv-contractors__attr { color: #999; font-size: 11px; margin-top: .6em; }
+ .rentv-contractors[hidden] { display: none; }
+</style>
+
+<script>
+(function () {
+ 'use strict';
+ if (window.RentvContractors) return; // idempotent — safe to include more than once
+
+ function esc(s) {
+ return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
+ return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
+ });
+ }
+
+ function marketLabel(m) {
+ if (!m) return 'this market';
+ return m.city || m.county || 'this market';
+ }
+
+ function renderList(el, data) {
+ var contractors = (data && data.contractors) || [];
+ if (!contractors.length) { el.hidden = true; return; } // nothing to show -> hide, story stays clean
+
+ var label = marketLabel(data.market);
+ var rows = contractors.map(function (c) {
+ var bits = [];
+ bits.push('<span class="rentv-contractors__name">' + esc(c.name) + '</span>');
+ if (c.trade) bits.push('<span class="rentv-contractors__trade"> · ' + esc(c.trade) + '</span>');
+ var phone = c.phone
+ ? '<div class="rentv-contractors__phone">' + esc(c.phone) + '</div>'
+ : '';
+ var meta = [];
+ if (c.city) meta.push(esc(c.city));
+ if (c.license_no) meta.push('Lic. ' + esc(c.license_no));
+ var metaLine = meta.length
+ ? '<div class="rentv-contractors__meta">' + meta.join(' · ') + '</div>'
+ : '';
+ return '<li class="rentv-contractors__item">' + bits.join('') + phone + metaLine + '</li>';
+ }).join('');
+
+ el.innerHTML =
+ '<div class="rentv-contractors__title">Licensed contractors in ' + esc(label) + '</div>' +
+ '<ul class="rentv-contractors__list">' + rows + '</ul>' +
+ '<div class="rentv-contractors__attr">' +
+ esc((data && data.attribution) || 'CA CSLB licensed contractors') +
+ '</div>';
+ el.hidden = false;
+ }
+
+ function render(el, opts) {
+ if (!el) return;
+ opts = opts || {};
+ var endpoint = (opts.endpoint || el.getAttribute('data-endpoint') || '/contrib/contractors')
+ .replace(/\/+$/, '');
+ var county = opts.county || el.getAttribute('data-county') || '';
+ var city = opts.city || el.getAttribute('data-city') || '';
+ var limit = opts.limit || el.getAttribute('data-limit') || '';
+ var mode = opts.mode || el.getAttribute('data-mode') || 'deal';
+
+ if (!county && !city) { el.hidden = true; return; }
+
+ var qs = new URLSearchParams({ mode: mode });
+ if (county) qs.set('county', county);
+ if (city) qs.set('city', city);
+ if (limit) qs.set('limit', limit);
+
+ fetch(endpoint + '/api/contractors-for-market?' + qs.toString(), {
+ headers: { accept: 'application/json' },
+ credentials: 'same-origin'
+ })
+ .then(function (r) { return r.ok ? r.json() : { contractors: [] }; })
+ .then(function (data) { renderList(el, data); })
+ .catch(function () { el.hidden = true; }); // fail soft — never break the story
+ }
+
+ function autoInit(root) {
+ var scope = root || document;
+ var nodes = scope.querySelectorAll('.rentv-contractors:not([data-rendered])');
+ Array.prototype.forEach.call(nodes, function (el) {
+ el.setAttribute('data-rendered', '1');
+ render(el);
+ });
+ }
+
+ window.RentvContractors = { render: render, autoInit: autoInit };
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', function () { autoInit(); });
+ } else {
+ autoInit();
+ }
+})();
+</script>
(oldest)
·
back to Rentv V1
·
Date every contractor entry: source_as_of + verify-at-cslb.c ff4651c →