← back to Rentv Licensed Targets
Add searchable/sortable web viewer over 102k rentv_licensed_targets (server.js + grid UI, sort+density, PG-parameterized)
a92114ac9d81bc1b78f394eae1fe1d44ecd1bfdf · 2026-08-10 17:06:25 -0700 · steve
Files touched
A package-lock.jsonA package.jsonA public/index.htmlA server.js
Diff
commit a92114ac9d81bc1b78f394eae1fe1d44ecd1bfdf
Author: steve <steve@designerwallcoverings.com>
Date: Mon Aug 10 17:06:25 2026 -0700
Add searchable/sortable web viewer over 102k rentv_licensed_targets (server.js + grid UI, sort+density, PG-parameterized)
---
package-lock.json | 178 ++++++++++++++++++++++++++++++++++++++++++++++++++
package.json | 13 ++++
public/index.html | 191 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
server.js | 142 ++++++++++++++++++++++++++++++++++++++++
4 files changed, 524 insertions(+)
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..72ac59c
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,178 @@
+{
+ "name": "rentv-licensed-targets-viewer",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "rentv-licensed-targets-viewer",
+ "version": "0.1.0",
+ "dependencies": {
+ "pg": "^8.23.0"
+ }
+ },
+ "../../.npm-global/lib/node_modules/playwright": {
+ "version": "1.61.0",
+ "extraneous": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.61.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/pg": {
+ "version": "8.23.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
+ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
+ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..a1f6f63
--- /dev/null
+++ b/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "rentv-licensed-targets-viewer",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Searchable/sortable web viewer over the 102k rentv_licensed_targets (RENTV commercial advertiser prospects)",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js"
+ },
+ "dependencies": {
+ "pg": "^8.23.0"
+ }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..90a2136
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,191 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>RENTV Commercial Targets — 102k Licensed Prospects</title>
+<style>
+ :root{
+ --bg:#0e1116; --panel:#161b22; --line:#232b36; --ink:#e6edf3; --dim:#8b98a8;
+ --accent:#2f9e77; --chip:#1d2530; --rowh:34px; --fs:13px;
+ }
+ *{box-sizing:border-box}
+ body{margin:0;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--ink)}
+ header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;gap:14px;flex-wrap:wrap}
+ header h1{font-size:16px;margin:0;font-weight:650;letter-spacing:.2px}
+ header .sub{color:var(--dim);font-size:12px}
+ .bar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:12px 18px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:5}
+ input,select{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:7px 9px;font-size:13px}
+ input#q{min-width:280px}
+ .bar label{color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.4px;margin-right:-4px}
+ .density{display:flex;align-items:center;gap:7px;margin-left:auto}
+ .density input[type=range]{accent-color:var(--accent)}
+ .count{color:var(--dim);font-size:12px;white-space:nowrap}
+ .count b{color:var(--ink)}
+ .wrap{overflow:auto;height:calc(100vh - 118px)}
+ table{border-collapse:collapse;width:100%;font-size:var(--fs)}
+ thead th{position:sticky;top:0;background:var(--panel);border-bottom:1px solid var(--line);text-align:left;
+ padding:8px 10px;white-space:nowrap;cursor:pointer;user-select:none;font-weight:600;color:#cdd6e0}
+ thead th .ar{color:var(--accent);font-size:10px;margin-left:3px}
+ tbody td{border-bottom:1px solid var(--line);padding:0 10px;height:var(--rowh);white-space:nowrap;max-width:340px;overflow:hidden;text-overflow:ellipsis}
+ tbody tr:hover{background:#131922}
+ .role{display:inline-block;padding:1px 7px;border-radius:10px;background:var(--chip);color:#bcd;font-size:11px}
+ .status{font-size:11px;color:var(--dim)}
+ .st-CLEAR,.st-Active,.st-Issued,.st-Licensed{color:#5fd0a0}
+ .st-Denied,.st-Pending{color:#e0a35f}
+ a{color:#6cb7ff;text-decoration:none} a:hover{text-decoration:underline}
+ .when{color:var(--dim);font-size:11px}
+ .pager{display:flex;gap:8px;align-items:center;padding:9px 18px;border-top:1px solid var(--line);background:var(--bg)}
+ .pager button{background:var(--panel);border:1px solid var(--line);color:var(--ink);border-radius:7px;padding:6px 12px;cursor:pointer}
+ .pager button:disabled{opacity:.4;cursor:default}
+ .muted{color:var(--dim)}
+ .empty{padding:40px;text-align:center;color:var(--dim)}
+</style>
+</head>
+<body>
+<header>
+ <h1>RENTV Commercial Targets</h1>
+ <span class="sub">Licensed advertiser-prospect universe · CSLB · AZ ROC · DFPI · DRE · State Bar · DOI — all commercial, within 300mi</span>
+</header>
+
+<div class="bar">
+ <input id="q" placeholder="Search entity, contact, license #, city, phone…" autocomplete="off">
+ <label>Role</label><select id="role"></select>
+ <label>Market</label><select id="market"></select>
+ <label>Status</label><select id="license_status"></select>
+ <label>Source</label><select id="source"></select>
+ <span class="count" id="count">…</span>
+ <div class="density">
+ <span class="muted" style="font-size:11px">Density</span>
+ <input type="range" id="density" min="0" max="4" step="1" value="1" title="Row density">
+ </div>
+</div>
+
+<div class="wrap">
+ <table>
+ <thead><tr id="head"></tr></thead>
+ <tbody id="rows"></tbody>
+ </table>
+ <div class="empty" id="empty" hidden>No matches.</div>
+</div>
+
+<div class="pager">
+ <button id="prev">‹ Prev</button>
+ <span class="count">Page <b id="page">1</b> <span class="muted">of</span> <b id="pages">1</b></span>
+ <button id="next">Next ›</button>
+ <span class="muted" id="range"></span>
+</div>
+
+<script>
+const LS = 'rentv-targets-view';
+const COLS = [
+ ['entity_name','Entity'], ['role','Role'], ['contact_name','Contact'],
+ ['license_no','License #'], ['license_type','Type'], ['license_status','Status'],
+ ['city','City'], ['county','County'], ['market','Market'],
+ ['phone','Phone'], ['website','Website'], ['source','Source'], ['scraped_at','Imported'],
+];
+const DENSITIES = [ // [rowHeight, fontSize]
+ ['26px','12px'], ['34px','13px'], ['40px','14px'], ['48px','15px'], ['58px','16px'],
+];
+
+const state = Object.assign(
+ { q:'', role:'', market:'', license_status:'', source:'', sort:'entity_name', dir:'asc', page:1, limit:50, density:1 },
+ JSON.parse(localStorage.getItem(LS) || '{}')
+);
+const save = () => localStorage.setItem(LS, JSON.stringify(state));
+const $ = (id) => document.getElementById(id);
+
+function applyDensity(){
+ const [h,f] = DENSITIES[state.density] || DENSITIES[1];
+ document.documentElement.style.setProperty('--rowh', h);
+ document.documentElement.style.setProperty('--fs', f);
+ $('density').value = state.density;
+}
+
+function renderHead(){
+ $('head').innerHTML = COLS.map(([k,label])=>{
+ const ar = state.sort===k ? `<span class="ar">${state.dir==='asc'?'▲':'▼'}</span>` : '';
+ return `<th data-k="${k}">${label}${ar}</th>`;
+ }).join('');
+ $('head').querySelectorAll('th').forEach(th=>th.onclick=()=>{
+ const k = th.dataset.k;
+ if (state.sort===k) state.dir = state.dir==='asc'?'desc':'asc';
+ else { state.sort=k; state.dir='asc'; }
+ state.page=1; save(); load();
+ });
+}
+
+function esc(s){ return (s==null?'':String(s)).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
+function fmtDate(s){
+ if(!s) return '';
+ try{ return new Date(s).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }
+ catch{ return s; }
+}
+function web(u){
+ if(!u) return '<span class="muted">—</span>';
+ const href = /^https?:/.test(u)?u:'https://'+u;
+ return `<a href="${esc(href)}" target="_blank" rel="noopener noreferrer">${esc(u.replace(/^https?:\/\//,''))}</a>`;
+}
+
+function renderRows(data){
+ const empty = data.rows.length===0;
+ $('empty').hidden = !empty;
+ $('rows').innerHTML = data.rows.map(r=>`<tr>
+ <td title="${esc(r.entity_name)}"><b>${esc(r.entity_name)}</b></td>
+ <td><span class="role">${esc(r.role)}</span></td>
+ <td>${esc(r.contact_name)||'<span class=muted>—</span>'}</td>
+ <td>${esc(r.license_no)||'<span class=muted>—</span>'}</td>
+ <td class="muted">${esc(r.license_type)}</td>
+ <td class="status st-${esc(r.license_status)}">${esc(r.license_status)}</td>
+ <td>${esc(r.city)}</td>
+ <td class="muted">${esc(r.county)}</td>
+ <td>${esc(r.market)}</td>
+ <td>${esc(r.phone)||'<span class=muted>—</span>'}</td>
+ <td>${web(r.website)}</td>
+ <td class="muted">${esc(r.source)}</td>
+ <td class="when" title="${esc(r.scraped_at)}">${fmtDate(r.scraped_at)}</td>
+ </tr>`).join('');
+}
+
+async function load(){
+ const p = new URLSearchParams();
+ for (const k of ['q','role','market','license_status','source','sort','dir','page','limit'])
+ if (state[k]!=='' && state[k]!=null) p.set(k, state[k]);
+ const data = await (await fetch('/api/targets?'+p)).json();
+ renderHead();
+ renderRows(data);
+ const pages = Math.max(1, Math.ceil(data.total/data.limit));
+ const start = data.total? (data.page-1)*data.limit+1 : 0;
+ const end = Math.min(data.page*data.limit, data.total);
+ $('count').innerHTML = `<b>${data.total.toLocaleString()}</b> targets`;
+ $('page').textContent = data.page; $('pages').textContent = pages.toLocaleString();
+ $('range').textContent = data.total? `showing ${start.toLocaleString()}–${end.toLocaleString()}` : '';
+ $('prev').disabled = data.page<=1; $('next').disabled = data.page>=pages;
+}
+
+function fillSelect(id, rows, label){
+ const cur = state[id];
+ $(id).innerHTML = `<option value="">All ${label}</option>` +
+ rows.map(r=>`<option value="${esc(r.v)}">${esc(r.v)} (${r.n.toLocaleString()})</option>`).join('');
+ $(id).value = cur;
+ $(id).onchange = ()=>{ state[id]=$(id).value; state.page=1; save(); load(); };
+}
+
+async function boot(){
+ applyDensity();
+ $('q').value = state.q;
+ const f = await (await fetch('/api/facets')).json();
+ fillSelect('role', f.role, 'roles');
+ fillSelect('market', f.market, 'markets');
+ fillSelect('license_status', f.license_status, 'statuses');
+ fillSelect('source', f.source, 'sources');
+ let t; $('q').oninput = ()=>{ clearTimeout(t); t=setTimeout(()=>{ state.q=$('q').value.trim(); state.page=1; save(); load(); },250); };
+ $('density').oninput = ()=>{ state.density=+$('density').value; save(); applyDensity(); };
+ $('prev').onclick = ()=>{ if(state.page>1){state.page--;save();load();} };
+ $('next').onclick = ()=>{ state.page++;save();load(); };
+ load();
+}
+boot();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..cb5d3ce
--- /dev/null
+++ b/server.js
@@ -0,0 +1,142 @@
+#!/usr/bin/env node
+'use strict';
+/*
+ * rentv-licensed-targets viewer
+ * Searchable / sortable grid over the 102k rentv_licensed_targets table
+ * (RENTV commercial advertiser-prospect universe: CA CSLB contractors,
+ * AZ ROC, DFPI lenders/escrow, DRE PM/subdivision, state bar, DOI title).
+ *
+ * Zero framework: Node built-in http + pg (parameterized queries only).
+ * Basic Auth admin / DW2024! (override via VIEWER_USER / VIEWER_PASS).
+ */
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { Pool } = require('pg');
+
+const PORT = Number(process.env.PORT || 0); // 0 = OS picks a free port
+const HOST = process.env.HOST || '127.0.0.1';
+const USER = process.env.VIEWER_USER || 'admin';
+const PASS = process.env.VIEWER_PASS || 'DW2024!';
+
+const pool = new Pool({
+ host: process.env.PGHOST || '/tmp',
+ database: process.env.PGDATABASE || 'realestate',
+ max: 6,
+});
+const TABLE = 'rentv_licensed_targets';
+
+// Columns the client is allowed to sort by (whitelist → no injection surface).
+const SORTABLE = new Set([
+ 'entity_name', 'contact_name', 'role', 'license_no', 'license_type',
+ 'license_status', 'city', 'county', 'market', 'phone', 'website',
+ 'source', 'scraped_at', 'id',
+]);
+// Exact-match filter columns (value bound as a parameter).
+const FILTERS = ['role', 'market', 'license_status', 'source', 'county'];
+// Free-text search columns (ILIKE against one bound %q% param).
+const SEARCH_COLS = ['entity_name', 'contact_name', 'license_no', 'city', 'address', 'phone'];
+
+function timingSafeEqual(a, b) {
+ const ab = Buffer.from(a), bb = Buffer.from(b);
+ if (ab.length !== bb.length) return false;
+ return crypto.timingSafeEqual(ab, bb);
+}
+function authed(req) {
+ const h = req.headers.authorization || '';
+ if (!h.startsWith('Basic ')) return false;
+ const [u, p] = Buffer.from(h.slice(6), 'base64').toString('utf8').split(':');
+ return timingSafeEqual(u || '', USER) && timingSafeEqual(p || '', PASS);
+}
+
+// Build the shared WHERE clause + bound params from the query string.
+function buildWhere(qs) {
+ const clauses = [];
+ const params = [];
+ for (const col of FILTERS) {
+ const v = qs.get(col);
+ if (v) { params.push(v); clauses.push(`${col} = $${params.length}`); }
+ }
+ const q = (qs.get('q') || '').trim();
+ if (q) {
+ params.push(`%${q}%`);
+ const idx = params.length;
+ clauses.push('(' + SEARCH_COLS.map((c) => `${c} ILIKE $${idx}`).join(' OR ') + ')');
+ }
+ return { where: clauses.length ? 'WHERE ' + clauses.join(' AND ') : '', params };
+}
+
+async function apiTargets(qs) {
+ const { where, params } = buildWhere(qs);
+
+ let sort = qs.get('sort') || 'entity_name';
+ if (!SORTABLE.has(sort)) sort = 'entity_name';
+ const dir = (qs.get('dir') || 'asc').toLowerCase() === 'desc' ? 'DESC' : 'ASC';
+
+ let limit = Math.min(Math.max(parseInt(qs.get('limit'), 10) || 50, 1), 200);
+ let page = Math.max(parseInt(qs.get('page'), 10) || 1, 1);
+ const offset = (page - 1) * limit;
+
+ const countSql = `SELECT count(*)::bigint AS n FROM ${TABLE} ${where}`;
+ const countRes = await pool.query(countSql, params);
+ const total = Number(countRes.rows[0].n);
+
+ // NULLS LAST keeps blank contact/website at the bottom regardless of dir.
+ const rowsSql =
+ `SELECT id, source, role, entity_name, contact_name, license_no, license_type,
+ license_status, address, city, county, state, zip, phone, website,
+ market, scraped_at
+ FROM ${TABLE} ${where}
+ ORDER BY ${sort} ${dir} NULLS LAST, id ASC
+ LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
+ const rowsRes = await pool.query(rowsSql, [...params, limit, offset]);
+
+ return { total, page, limit, sort, dir, rows: rowsRes.rows };
+}
+
+let facetCache = null, facetAt = 0;
+async function apiFacets() {
+ if (facetCache && Date.now() - facetAt < 5 * 60 * 1000) return facetCache;
+ const facet = async (col) => (await pool.query(
+ `SELECT ${col} AS v, count(*)::int AS n FROM ${TABLE}
+ WHERE ${col} IS NOT NULL AND ${col} <> '' GROUP BY ${col} ORDER BY n DESC`
+ )).rows;
+ const [role, market, license_status, source, county] = await Promise.all(
+ ['role', 'market', 'license_status', 'source', 'county'].map(facet)
+ );
+ const total = Number((await pool.query(`SELECT count(*)::bigint n FROM ${TABLE}`)).rows[0].n);
+ facetCache = { total, role, market, license_status, source, county };
+ facetAt = Date.now();
+ return facetCache;
+}
+
+function send(res, code, body, type = 'application/json') {
+ res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-store' });
+ res.end(typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body));
+}
+
+const server = http.createServer(async (req, res) => {
+ if (!authed(req)) {
+ res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="rentv-targets"' });
+ return res.end('Auth required');
+ }
+ const url = new URL(req.url, 'http://x');
+ try {
+ if (url.pathname === '/' || url.pathname === '/index.html') {
+ return send(res, 200, fs.readFileSync(path.join(__dirname, 'public', 'index.html')), 'text/html; charset=utf-8');
+ }
+ if (url.pathname === '/api/facets') return send(res, 200, await apiFacets());
+ if (url.pathname === '/api/targets') return send(res, 200, await apiTargets(url.searchParams));
+ if (url.pathname === '/healthz') return send(res, 200, { ok: true });
+ return send(res, 404, { error: 'not found' });
+ } catch (e) {
+ console.error(e);
+ return send(res, 500, { error: String(e.message || e) });
+ }
+});
+
+server.listen(PORT, HOST, () => {
+ const p = server.address().port;
+ console.log(`rentv-licensed-targets viewer live: http://${HOST}:${p} (login ${USER} / ${PASS})`);
+});
← a27680f feat(scraper): ca_dre_pm — Property Management from DRE regi
·
back to Rentv Licensed Targets
·
chore: lint (URL/path guards, pg error handler, drop pw from 283e561 →