← back to Rentv Adintel
src/connectors/gsc.js
292 lines
'use strict';
/**
* Google Search Console connector — spec §18.
*
* Fixture-backed when GSC_SITE_URL / GOOGLE_SERVICE_ACCOUNT_JSON_BASE64 are
* absent (the default). Loads gsc-queries.json and gsc-pages.json, classifies
* each query for brand/nonbrand and topic cluster, then UPSERTs into
* gsc_query_metrics / gsc_page_metrics with is_demo=true.
*
* IMPORTANT: Search Console data represents ORGANIC search only. Never label
* it as paid search.
*
* @module src/connectors/gsc
*/
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { query } = require('../../db');
// ---------------------------------------------------------------------------
// Credentials probe
// ---------------------------------------------------------------------------
function hasCredentials() {
return Boolean(process.env.GOOGLE_SERVICE_ACCOUNT_JSON_BASE64 && process.env.GSC_SITE_URL);
}
/**
* @returns {{ connected: boolean, demo: boolean, error?: string }}
*/
async function connectionTest() {
if (!hasCredentials()) {
return { connected: false, demo: true, error: 'No GSC_SITE_URL / GOOGLE_SERVICE_ACCOUNT_JSON_BASE64 configured — running in DEMO/fixture mode.' };
}
// TODO real API: use googleapis searchconsole.sites.getQueryReport or
// the webmasters.searchanalytics.query endpoint:
//
// const { google } = require('googleapis');
// const creds = JSON.parse(Buffer.from(process.env.GOOGLE_SERVICE_ACCOUNT_JSON_BASE64,'base64').toString('utf8'));
// const auth = new google.auth.GoogleAuth({ credentials: creds, scopes: ['https://www.googleapis.com/auth/webmasters.readonly'] });
// const sc = google.searchconsole({ version: 'v1', auth });
// await sc.sites.get({ siteUrl: process.env.GSC_SITE_URL });
throw new Error('GSC live API not enabled (no service account)');
}
// ---------------------------------------------------------------------------
// Brand & cluster classification — spec §18
// ---------------------------------------------------------------------------
/**
* Classify a query as brand or nonbrand.
* Brand = contains the token 'rentv' (case-insensitive).
*
* @param {string} queryStr
* @returns {boolean}
*/
function classifyBrandVsNonbrand(queryStr) {
if (!queryStr || typeof queryStr !== 'string') return false;
return /rentv/i.test(queryStr);
}
/**
* Assign one topic cluster to a query string.
* Implements the §18 cluster taxonomy.
*
* Priority order matters: more specific patterns first.
*
* @param {string} queryStr
* @returns {string} one of the cluster keys defined in spec §18
*/
function clusterQuery(queryStr) {
if (!queryStr || typeof queryStr !== 'string') return 'other';
const q = queryStr.toLowerCase();
// Conference / event signals
if (/\b(conference|summit|event|expo|forum|naiop|uli|boma|icsc|crew|sior|ccim|convention|symposium|award|gala)\b/.test(q)) {
return 'conference_event';
}
// Advertiser category / advertising signals
if (/\b(advertis|sponsor|media kit|newsletter|eblast|placement|property spotlight|cre talk)\b/.test(q)) {
return 'advertiser_category';
}
// Finance / lending signals
if (/\b(financ|lend|loan|mortgage|debt|capital|credit|rate|refinanc|bridge|mezzanine|cmbs|note|fund)\b/.test(q)) {
return 'finance_lending';
}
// Brokerage / deal signals
if (/\b(brokerage|broker|sale|sold|deal|acquisition|disposition|1031|nnn|net lease|cap rate|listing)\b/.test(q)) {
return 'brokerage_deal';
}
// Property type signals
if (/\b(office|industrial|retail|multifamily|apartment|warehouse|flex|mixed.use|data.center|lab|life.science|hotel|hospitality|land|development)\b/.test(q)) {
return 'property_type';
}
// California market signals
if (/\b(los angeles|la |orange county|irvine|san diego|inland empire|bay area|san francisco|sacramento|ventura|pasadena|long beach|burbank|riverside|ontario|santa ana|socal|southern california|northern california|california|ca )\b/.test(q)) {
return 'california_market';
}
// Arizona market signals
if (/\b(phoenix|scottsdale|tempe|mesa|chandler|gilbert|glendale|tucson|arizona|az )\b/.test(q)) {
return 'arizona_market';
}
return 'other';
}
// ---------------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------------
const FIXTURES_DIR = path.resolve(__dirname, '../../fixtures');
function loadFixture(name) {
return JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, name), 'utf8'));
}
function fixtureChecksum(name) {
return crypto.createHash('sha256').update(fs.readFileSync(path.join(FIXTURES_DIR, name))).digest('hex');
}
// ---------------------------------------------------------------------------
// Import run bookkeeping (mirrors ga4.js pattern)
// ---------------------------------------------------------------------------
async function startRun(kind, sourceFile, checksum) {
const res = await query(
`INSERT INTO analytics_import_runs (kind, source_file, checksum, is_demo, status)
VALUES ($1,$2,$3,true,'RUNNING') RETURNING id`,
[kind, sourceFile, checksum]
);
return res.rows[0].id;
}
async function finishRun(runId, rowCount, error) {
await query(
`UPDATE analytics_import_runs
SET finished_at = now(), status = $2, row_count = $3, error = $4
WHERE id = $1`,
[runId, error ? 'ERROR' : 'SUCCESS', rowCount, error || null]
);
}
// ---------------------------------------------------------------------------
// Importers
// ---------------------------------------------------------------------------
/**
* Load gsc-queries.json → gsc_query_metrics.
* Applies classifyBrandVsNonbrand() + clusterQuery() to each row.
*
* @param {boolean} dryRun
* @returns {{ inserted: number, skipped: number, rejected: [], runId: string|null, dryRun: boolean }}
*/
async function importQueries(dryRun = false) {
const fixture = loadFixture('gsc-queries.json');
const rows = fixture.rows;
const checksum = fixtureChecksum('gsc-queries.json');
let runId = null;
let inserted = 0;
if (!dryRun) {
runId = await startRun('GSC_QUERIES', 'fixtures/gsc-queries.json', checksum);
const dates = [...new Set(rows.map((r) => r.metric_date))];
for (const d of dates) {
await query('DELETE FROM gsc_query_metrics WHERE metric_date = $1 AND is_demo = true', [d]);
}
}
try {
for (const row of rows) {
if (!row.metric_date || !row.query) continue;
const isBrand = classifyBrandVsNonbrand(row.query);
const cluster = clusterQuery(row.query);
if (!dryRun) {
await query(
`INSERT INTO gsc_query_metrics
(metric_date, query, country, device, clicks, impressions, ctr, position,
is_brand, cluster, is_demo)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,true)`,
[
row.metric_date, row.query, row.country, row.device,
row.clicks, row.impressions, row.ctr, row.position,
isBrand, cluster,
]
);
}
inserted++;
}
if (!dryRun) await finishRun(runId, inserted, null);
return { inserted, skipped: 0, rejected: [], runId, dryRun };
} catch (err) {
if (!dryRun && runId) await finishRun(runId, inserted, err.message);
throw err;
}
}
/**
* Load gsc-pages.json → gsc_page_metrics.
*
* @param {boolean} dryRun
* @returns {{ inserted: number, skipped: number, rejected: [], runId: string|null, dryRun: boolean }}
*/
async function importPages(dryRun = false) {
const fixture = loadFixture('gsc-pages.json');
const rows = fixture.rows;
const checksum = fixtureChecksum('gsc-pages.json');
let runId = null;
let inserted = 0;
if (!dryRun) {
runId = await startRun('GSC_PAGES', 'fixtures/gsc-pages.json', checksum);
const dates = [...new Set(rows.map((r) => r.metric_date))];
for (const d of dates) {
await query('DELETE FROM gsc_page_metrics WHERE metric_date = $1 AND is_demo = true', [d]);
}
}
try {
for (const row of rows) {
if (!row.metric_date || !row.page) continue;
if (!dryRun) {
await query(
`INSERT INTO gsc_page_metrics
(metric_date, page, country, device, clicks, impressions, ctr, position, is_demo)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,true)`,
[
row.metric_date, row.page, row.country, row.device,
row.clicks, row.impressions, row.ctr, row.position,
]
);
}
inserted++;
}
if (!dryRun) await finishRun(runId, inserted, null);
return { inserted, skipped: 0, rejected: [], runId, dryRun };
} catch (err) {
if (!dryRun && runId) await finishRun(runId, inserted, err.message);
throw err;
}
}
/**
* Import all GSC data (queries + pages).
*
* TODO real API: replace fixture branches with googleapis searchconsole calls:
*
* const body = {
* startDate: '2026-05-08',
* endDate: '2026-08-06',
* dimensions: ['query', 'country', 'device', 'date'],
* rowLimit: 25000,
* };
* const res = await sc.searchanalytics.query({ siteUrl: process.env.GSC_SITE_URL, requestBody: body });
* // paginate via startRow until res.data.rows.length < rowLimit
*
* @param {{ dryRun?: boolean }} options
* @returns {Promise<{ queries: object, pages: object, demo: true }>}
*/
async function importAll({ dryRun = false } = {}) {
if (hasCredentials()) {
throw new Error('GSC live API not enabled (no service account) — implement the TODO real API branch.');
}
const [queries, pages] = await Promise.all([
importQueries(dryRun),
importPages(dryRun),
]);
return { queries, pages, demo: true };
}
module.exports = {
connectionTest,
importAll,
importQueries,
importPages,
classifyBrandVsNonbrand,
clusterQuery,
hasCredentials,
};