← back to Commercialrealestate
scripts/sources/loopnet.js
94 lines
// sources/loopnet.js — LoopNet (CoStar) cross-reference for EVERY listing.
//
// TWO honest tiers, no fabrication:
//
// 1. deepLink({address,city,zip,type}) — FREE, $0, zero legal exposure.
// Pure URL construction. Returns links that OPEN LoopNet for this property:
// • exact — a `site:loopnet.com "<address>"` Google deep-link that surfaces the
// specific LoopNet listing if one exists (most reliable for a single address).
// • browse — a LoopNet city + property-type for-sale search page.
// This is how we put "LoopNet on all listings" instantly: a click-through, not a scrape.
//
// 2. getListing({address,city,zip}) — CREDENTIALED, GATED, returns {available:false,...} until
// Steve provides a CoStar/LoopNet authenticated session or enterprise API access. Structured
// LoopNet listing data (price, cap rate, units, NOI, broker) is behind CoStar's login + anti-bot,
// and CoStar actively litigates scrapers (CoStar v. Crexi). So we DO NOT mass-scrape it here.
// The existing per-property Browserbase puller (/api/comps -> live-comps-bb.js, ~$0.03/call) stays
// the opt-in "pull live data" path; this adapter is the slot for a proper credentialed feed.
//
// PLUG-IN CONTRACT (mirrors title-records.js so it composes with the rest of the source layer):
// status() -> { available:boolean, needs?:string, provider?:string }
// deepLink(prop) -> { exact:string, browse:string } // always available, $0
// getListing(prop) -> { available:false, needs:string } // until credentialed
// -> { available:true, matched:boolean, listing }// when credentialed
'use strict';
const CREDENTIAL_ENV = 'LOOPNET_API_KEY'; // CoStar/LoopNet enterprise key or session token
const BASE_URL_ENV = 'LOOPNET_BASE_URL'; // provider API base when applicable
function readCredential() {
const fromEnv = process.env[CREDENTIAL_ENV];
if (fromEnv) return { key: fromEnv, baseUrl: process.env[BASE_URL_ENV] || null };
try {
const fs = require('fs'), path = require('path');
const env = fs.readFileSync(path.join(__dirname, '..', '..', '.env'), 'utf8');
const m = env.match(new RegExp('^' + CREDENTIAL_ENV + '=(.*)$', 'm'));
if (m) return {
key: m[1].replace(/['"]/g, '').trim(),
baseUrl: (env.match(new RegExp('^' + BASE_URL_ENV + '=(.*)$', 'm')) || [])[1]?.replace(/['"]/g, '').trim() || null,
};
} catch (_) {}
return null;
}
function status() {
const c = readCredential();
if (!c) return {
available: false,
provider: 'loopnet/costar',
needs: 'A CoStar/LoopNet authenticated session or enterprise API key. Set LOOPNET_API_KEY (+ optional ' +
'LOOPNET_BASE_URL) in .env. NOTE: free deep-links work today with no credential; the credential ' +
'only unlocks structured listing pulls (price/cap/units/NOI). Mass-scraping LoopNet is NOT done here — ' +
'CoStar litigates scrapers (CoStar v. Crexi).',
};
return { available: true, provider: 'loopnet/costar' };
}
// our asset TYPE -> LoopNet property-type slug (best-effort; falls back to a generic search).
const TYPE_SLUG = {
'Multifamily': 'multifamily', 'multifamily': 'multifamily',
'Office': 'office', 'office': 'office',
'Retail': 'retail', 'retail': 'retail',
'Industrial': 'industrial', 'industrial': 'industrial',
'Mixed-use': 'mixed-use', 'mixed-use': 'mixed-use', 'Mixed Use': 'mixed-use',
'Land': 'land', 'land': 'land',
};
const slug = s => String(s || '').toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
// FREE, $0, no fetch — just builds the URLs. Always available.
function deepLink({ address, city, zip, type, state } = {}) {
const st = (state || 'ca').toLowerCase();
const citySlug = city ? `${slug(city)}-${st}` : `los-angeles-${st}`;
const ptype = TYPE_SLUG[type] || '';
// exact: Google site-search reliably resolves the specific LoopNet listing for an address.
const q = encodeURIComponent(`site:loopnet.com "${[address, city].filter(Boolean).join(' ')}"`.trim());
const exact = `https://www.google.com/search?q=${q}`;
// browse: LoopNet city (+ type) for-sale search page.
const browse = ptype
? `https://www.loopnet.com/search/${ptype}/${citySlug}/for-sale/`
: `https://www.loopnet.com/search/commercial-real-estate/${citySlug}/for-sale/`;
return { exact, browse };
}
// CREDENTIALED structured pull — inert until a credential lands. Fabricates NOTHING.
async function getListing(prop) {
const s = status();
if (!s.available) return { available: false, needs: s.needs };
// When LOOPNET_API_KEY is configured, implement the real provider call here.
// Until then we never reach this branch.
return { available: false, needs: s.needs };
}
module.exports = { status, deepLink, getListing, TYPE_SLUG };