diff --git a/Scripts/GAS_GS/Williams-Sonoma-Property-Scraper/.clasp.json b/Scripts/GAS_GS/Williams-Sonoma-Property-Scraper/.clasp.json new file mode 100644 index 00000000..ab93ea22 --- /dev/null +++ b/Scripts/GAS_GS/Williams-Sonoma-Property-Scraper/.clasp.json @@ -0,0 +1,16 @@ +{ + "scriptId": "1GO6XQKg9AiO-CDXBNGZaAbznyXj--TzMBlcZBUe3cKU6Xnw-Vo6yQFhY", + "rootDir": "", + "scriptExtensions": [ + ".js", + ".gs" + ], + "htmlExtensions": [ + ".html" + ], + "jsonExtensions": [ + ".json" + ], + "filePushOrder": [], + "skipSubdirectories": false +} \ No newline at end of file diff --git a/Scripts/GAS_GS/Williams-Sonoma-Property-Scraper/appsscript.json b/Scripts/GAS_GS/Williams-Sonoma-Property-Scraper/appsscript.json new file mode 100644 index 00000000..3cf1d247 --- /dev/null +++ b/Scripts/GAS_GS/Williams-Sonoma-Property-Scraper/appsscript.json @@ -0,0 +1,7 @@ +{ + "timeZone": "America/New_York", + "dependencies": { + }, + "exceptionLogging": "STACKDRIVER", + "runtimeVersion": "V8" +} \ No newline at end of file diff --git a/Scripts/GAS_GS/Williams-Sonoma-Property-Scraper/extractPersonToNewSheet.js b/Scripts/GAS_GS/Williams-Sonoma-Property-Scraper/extractPersonToNewSheet.js new file mode 100644 index 00000000..93a37f03 --- /dev/null +++ b/Scripts/GAS_GS/Williams-Sonoma-Property-Scraper/extractPersonToNewSheet.js @@ -0,0 +1,226 @@ +/*************** + * CONFIG + ***************/ +const CONFIG = { + SPREADSHEET_ID: "17II0V8EsTL-9nHq2HgqhHcay-t-hhOaY5wNEzXHEsAM", + SOURCE_SHEET_NAME: "WSMinMCA.csv", // tab name inside the spreadsheet + EMAIL_COLUMN_INDEX_1BASED: 3, // Column C = 3 (1-based) + SENDER: "nrasmussen@gainsight.com", + SUBJECT: "DATA REQUEST", + PROCESSED_LABEL: "DATA_REQUEST_PROCESSED", + TEMP_SHEET_PREFIX: "DATA_REQUEST_", // new tab prefix + MAX_THREADS_PER_RUN: 10 // safety throttle +}; + +/** + * Run once to create the time-driven trigger. + * (There is no true "email received" trigger for Gmail in Apps Script.) + */ +function setupTrigger() { + // Optional: delete existing triggers for this function to avoid duplicates + ScriptApp.getProjectTriggers() + .filter(t => t.getHandlerFunction() === "processDataRequests") + .forEach(t => ScriptApp.deleteTrigger(t)); + + // Create a trigger every 5 minutes (adjust as desired) + ScriptApp.newTrigger("processDataRequests") + .timeBased() + .everyMinutes(5) + .create(); +} + +/** + * Main worker: finds unread "DATA REQUEST" messages from aubrey@ws.com, + * extracts target email from body, filters rows, creates new tab, CSVs it, + * replies with attachment, and labels thread as processed. + */ +function processDataRequests() { + // const label = GmailApp.getUserLabelByName(CONFIG.PROCESSED_LABEL) || GmailApp.createLabel(CONFIG.PROCESSED_LABEL); + + // // Search unread matching threads that are not already labeled processed + // const query = [ + // `from:${CONFIG.SENDER}`, + // `subject:"${CONFIG.SUBJECT}"`, + // "is:unread", + // `-label:${CONFIG.PROCESSED_LABEL}` + // ].join(" "); + + const threads = GmailApp.search(query, 0, CONFIG.MAX_THREADS_PER_RUN); + Logger.log(threads); + // if (!threads.length) return; + + const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID); + const sourceSheet = ss.getSheetByName(CONFIG.SOURCE_SHEET_NAME); + if (!sourceSheet) throw new Error(`Sheet tab not found: ${CONFIG.SOURCE_SHEET_NAME}`); + + // Read all data once for efficiency + const sourceValues = sourceSheet.getDataRange().getValues(); + if (sourceValues.length < 1) throw new Error(`No data found in ${CONFIG.SOURCE_SHEET_NAME}`); + + const header = sourceValues[0]; + const emailColIdx0 = CONFIG.EMAIL_COLUMN_INDEX_1BASED - 1; + + threads.forEach(thread => { + const messages = thread.getMessages(); + + // Process only unread messages in the thread (usually just the newest) + messages.forEach(message => { + if (!message.isUnread()) return; + + try { + const plainBody = message.getPlainBody() || ""; + const targetEmail = extractFirstNonSenderEmail_(plainBody, CONFIG.SENDER); + + if (!targetEmail) { + message.reply( + `I received your DATA REQUEST, but I couldn't find a target email address in the message body.\n\n` + + `Please include an email like "someone@company.com" in the body and resend.` + ); + message.markRead(); + return; + } + + const targetLower = targetEmail.toLowerCase(); + + // Filter rows where Column C exactly matches the extracted email (case-insensitive) + const matches = sourceValues + .slice(1) // skip header + .filter(row => String(row[emailColIdx0] || "").trim().toLowerCase() === targetLower); + + // Create a new tab with results + const newSheetName = makeSafeSheetName_( + `${CONFIG.TEMP_SHEET_PREFIX}${targetLower}_${Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "yyyyMMdd_HHmmss")}` + ); + + const resultSheet = ss.insertSheet(newSheetName); + resultSheet.getRange(1, 1, 1, header.length).setValues([header]); + + if (matches.length) { + resultSheet.getRange(2, 1, matches.length, header.length).setValues(matches); + } + + // Convert result tab to CSV + const csv = valuesToCsv_(resultSheet.getDataRange().getValues()); + const blob = Utilities.newBlob(csv, "text/csv", `${targetLower}.csv`); + + // Reply with CSV attached (keeps threading) + const body = + `Attached is the CSV export for ${targetEmail}.\n\n` + + `Rows matched (excluding header): ${matches.length}\n` + + `Source tab: ${CONFIG.SOURCE_SHEET_NAME}\n` + + `Result tab: ${newSheetName}\n`; + + message.reply(body, { attachments: [blob] }); + + // Mark processed + message.markRead(); + thread.addLabel(label); + + // Optional cleanup: uncomment if you do NOT want to keep the result tab + // ss.deleteSheet(resultSheet); + + } catch (err) { + // Fail gracefully: reply with error so the sender knows it didn't work + message.reply( + `I attempted to process this DATA REQUEST but hit an error:\n\n${err && err.stack ? err.stack : err}` + ); + message.markRead(); + } + }); + }); +} + +function modifiedProcessDataRequests() { + const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID); + const sourceSheet = ss.getSheetByName(CONFIG.SOURCE_SHEET_NAME); + if (!sourceSheet) throw new Error(`Sheet tab not found: ${CONFIG.SOURCE_SHEET_NAME}`); + const targetEmail = "khodge2@wsgc.com"; + const targetLower = targetEmail.toLowerCase(); + + // Read all data once for efficiency + const sourceValues = sourceSheet.getDataRange().getValues(); + if (sourceValues.length < 1) throw new Error(`No data found in ${CONFIG.SOURCE_SHEET_NAME}`); + + const header = sourceValues[0]; + Logger.log(header); + const emailColIdx0 = CONFIG.EMAIL_COLUMN_INDEX_1BASED - 1; + Logger.log(emailColIdx0); + + // Filter rows where Column C exactly matches the extracted email (case-insensitive) + const matches = sourceValues + .slice(1) // skip header + .filter(row => String(row[emailColIdx0] || "").trim().toLowerCase() === targetLower); + + // Create a new tab with results + const newSheetName = makeSafeSheetName_( + `${CONFIG.TEMP_SHEET_PREFIX}${targetLower}_${Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "yyyyMMdd_HHmmss")}` + ); + + const resultSheet = ss.insertSheet(newSheetName); + resultSheet.getRange(1, 1, 1, header.length).setValues([header]); + + if (matches.length) { + resultSheet.getRange(2, 1, matches.length, header.length).setValues(matches); + } + + // Convert result tab to CSV + const csv = valuesToCsv_(resultSheet.getDataRange().getValues()); + const blob = Utilities.newBlob(csv, "text/csv", `${targetLower}.csv`); + + // Reply with CSV attached (keeps threading) + const body = + `Attached is the CSV export for ${targetEmail}.\n\n` + + `Rows matched (excluding header): ${matches.length}\n` + + `Source tab: ${CONFIG.SOURCE_SHEET_NAME}\n` + + `Result tab: ${newSheetName}\n`; + + Logger.log(body); + + // Optional cleanup: uncomment if you do NOT want to keep the result tab + // ss.deleteSheet(resultSheet); +} + +/**************** + * HELPERS + ****************/ + +/** + * Extracts the first email address found in the text that is NOT the sender email. + */ +function extractFirstNonSenderEmail_(text, senderEmail) { + const re = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/ig; + const found = text.match(re) || []; + const senderLower = (senderEmail || "").toLowerCase(); + const first = found + .map(e => e.trim()) + .find(e => e.toLowerCase() !== senderLower); + return first || null; +} + +/** + * Converts a 2D array of values to RFC-ish CSV: + * - Wrap fields in quotes if they contain comma, quote, or newline + * - Double internal quotes + */ +function valuesToCsv_(values) { + return values.map(row => row.map(cell => { + let s = cell === null || cell === undefined ? "" : String(cell); + // Normalize line endings inside cells + s = s.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const needsQuotes = /[",\n]/.test(s); + if (needsQuotes) s = `"${s.replace(/"/g, '""')}"`; + return s; + }).join(",")).join("\r\n"); +} + +/** + * Google Sheets tab names have limits; keep it safe. + */ +function makeSafeSheetName_(name) { + // Remove characters Sheets doesn't like: [ ] : * ? / \ + let safe = name.replace(/[\[\]:*?/\\]/g, " "); + safe = safe.replace(/\s+/g, " ").trim(); + // Max length 100 + if (safe.length > 100) safe = safe.slice(0, 100); + return safe || "DATA_REQUEST_RESULT"; +} diff --git a/Scripts/GAS_GS/Williams-Sonoma-Property-Scraper/newSheetGetProps.js b/Scripts/GAS_GS/Williams-Sonoma-Property-Scraper/newSheetGetProps.js new file mode 100644 index 00000000..8ccc5c5e --- /dev/null +++ b/Scripts/GAS_GS/Williams-Sonoma-Property-Scraper/newSheetGetProps.js @@ -0,0 +1,193 @@ +/*********************** + * PROPFIG (edit these) + ***********************/ +const PROPFIG = { + spreadsheetId: "17II0V8EsTL-9nHq2HgqhHcay-t-hhOaY5wNEzXHEsAM", + sheetId: 249063374, // the tab's sheetId (not gid string; usually same number) + courseCol: 6, // Column C = 3 + headerRow: 1, + startDataRow: 2, + northpassBaseUrl: "https://api.northpass.com", + cacheTtlSeconds: 6 * 60 * 60 // 6 hours +}; +/** + * One-time setup: + * Run setNorthpassApiKey_() to store your API key securely in Script Properties. + */ +function setNorthpassApiKey() { + const apiKey = "N5AQIwjIeeYDBSU9vTST37Nvg"; + PropertiesService.getScriptProperties().setProperty("NORTHPASS_API_KEY", apiKey); +} + +/** + * Main entrypoint: reads course names from col C and writes core_competencies + * into the first empty column (based on header row). + */ +function writeCoreCompetenciesFromCourses() { + const ss = SpreadsheetApp.openById(PROPFIG.spreadsheetId); + const sheet = getSheetById_(ss, PROPFIG.sheetId); + + const apiKey = PropertiesService.getScriptProperties().getProperty("NORTHPASS_API_KEY"); + if (!apiKey) throw new Error("Missing NORTHPASS_API_KEY. Run setNorthpassApiKey_() first."); + + // Determine output column: first empty cell in header row; if none, append. + const lastCol = Math.max(sheet.getLastColumn(), 1); + const headerValues = sheet.getRange(PROPFIG.headerRow, 1, 1, lastCol).getValues()[0]; + let outCol = headerValues.findIndex(v => String(v || "").trim() === "") + 1; + if (outCol === 0) outCol = lastCol + 1; + + // Write a header label (optional but useful) + sheet.getRange(PROPFIG.headerRow, outCol).setValue("core_competencies"); + + // Read course column values + const lastRow = sheet.getLastRow(); + if (lastRow < PROPFIG.startDataRow) return; + + const numRows = lastRow - PROPFIG.startDataRow + 1; + const courseValues = sheet.getRange(PROPFIG.startDataRow, PROPFIG.courseCol, numRows, 1).getValues(); + + // Prep caches: in-run memo + script cache + const runMemo = {}; // { courseNameLower: { id, coreCompetenciesString } } + const cache = CacheService.getScriptCache(); + + // Build output column values aligned to rows + const output = new Array(numRows).fill([""]); + + for (let i = 0; i < numRows; i++) { + const rawCourse = String(courseValues[i][0] || "").trim(); + if (!rawCourse) continue; + + const key = rawCourse.toLowerCase(); + + // 1) in-run memo + if (runMemo[key]) { + output[i] = [runMemo[key].coreCompetenciesString]; + continue; + } + + // 2) CacheService (persists across runs for TTL) + const cached = cache.get(cacheKey_(key)); + if (cached) { + const parsed = JSON.parse(cached); + runMemo[key] = parsed; + output[i] = [parsed.coreCompetenciesString]; + continue; + } + + // 3) Fetch from Northpass + const courseId = fetchCourseIdByExactName_(apiKey, rawCourse); + const coreCompetencies = fetchCoreCompetenciesByCourseId_(apiKey, courseId); + + const coreStr = normalizeToSingleString_(coreCompetencies); + + const record = { id: courseId, coreCompetenciesString: coreStr }; + runMemo[key] = record; + cache.put(cacheKey_(key), JSON.stringify(record), PROPFIG.cacheTtlSeconds); + + output[i] = [coreStr]; + } + + // Write results in one batch + sheet.getRange(PROPFIG.startDataRow, outCol, numRows, 1).setValues(output); +} + +/*********************** + * Northpass API helpers + ***********************/ + +/** + * Gets course id by exact name using: + * /v2/courses?filter[name][eq]= + * If no match is returned, falls back to: + * /v2/courses?filter[name]= + * + * Filtering is documented by Northpass. :contentReference[oaicite:3]{index=3} + */ +function fetchCourseIdByExactName_(apiKey, courseName) { + const encoded = encodeURIComponent(courseName); + + const urlsToTry = [ + `${PROPFIG.northpassBaseUrl}/v2/courses?filter[name][eq]=${encoded}`, + `${PROPFIG.northpassBaseUrl}/v2/courses?filter[name]=${encoded}` // fallback + ]; + + for (const url of urlsToTry) { + const json = northpassGetJson_(apiKey, url); + + // Typical Northpass JSON: { data: [ { id, type, attributes... }, ... ] } :contentReference[oaicite:4]{index=4} + const data = json && json.data; + if (Array.isArray(data) && data.length > 0 && data[0].id) { + return data[0].id; + } + } + + throw new Error(`No course found for name "${courseName}" (tried eq + fallback).`); +} + +/** + * Fetches course properties: + * /v2/properties/courses/{id} + * Returns: data.attributes.customized_properties.core_competencies :contentReference[oaicite:5]{index=5} + */ +function fetchCoreCompetenciesByCourseId_(apiKey, courseId) { + const url = `${PROPFIG.northpassBaseUrl}/v2/properties/courses/${encodeURIComponent(courseId)}`; + const json = northpassGetJson_(apiKey, url); + + const core = + json && + json.data && + json.data.attributes && + json.data.attributes.customized_properties && + json.data.attributes.customized_properties.core_competencies; + + // Could be undefined/null/string/array/object depending on how it's stored + return core ?? ""; +} + +/** + * Makes an authenticated GET request to Northpass using X-Api-Key header. + * Northpass API key auth is documented. :contentReference[oaicite:6]{index=6} + */ +function northpassGetJson_(apiKey, url) { + const resp = UrlFetchApp.fetch(url, { + method: "get", + muteHttpExceptions: true, + headers: { + "Accept": "application/json", + "X-Api-Key": apiKey + } + }); + + const code = resp.getResponseCode(); + const text = resp.getContentText(); + + if (code < 200 || code >= 300) { + throw new Error(`Northpass API error ${code} for ${url}\n${text}`); + } + return JSON.parse(text); +} + +/*********************** + * Sheet helpers + ***********************/ +function getSheetById_(spreadsheet, sheetId) { + const sheets = spreadsheet.getSheets(); + for (const s of sheets) { + if (s.getSheetId() === sheetId) return s; + } + throw new Error(`No sheet found with sheetId=${sheetId}`); +} + +/*********************** + * Formatting + caching + ***********************/ +function normalizeToSingleString_(value) { + if (value === null || value === undefined) return ""; + if (Array.isArray(value)) return value.map(v => String(v).trim()).filter(Boolean).join("; "); + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +function cacheKey_(courseNameLower) { + return `np_course_${courseNameLower}`; +}