← 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 };