From e182f87cace1181617ffd9b2312b4142b2763cd0 Mon Sep 17 00:00:00 2001 From: Bram Date: Mon, 4 May 2026 14:37:21 +0200 Subject: [PATCH] first version of browser extension hireflow --- .../HireflowInteractionTracker/Archive.zip | Bin 0 -> 6316 bytes .../HireflowInteractionTracker/background.js | 9 + .../content/tracker.js | 317 ++++++++++++++++++ .../icons/icon16.png | Bin 0 -> 82 bytes .../icons/icon48.png | Bin 0 -> 125 bytes .../icons/icon96.png | Bin 0 -> 216 bytes .../HireflowInteractionTracker/manifest.json | 30 ++ .../HireflowInteractionTracker/popup.css | 119 +++++++ .../HireflowInteractionTracker/popup.html | 33 ++ .../HireflowInteractionTracker/popup.js | 216 ++++++++++++ 10 files changed, 724 insertions(+) create mode 100644 BrowserExtensions/HireflowInteractionTracker/Archive.zip create mode 100644 BrowserExtensions/HireflowInteractionTracker/background.js create mode 100644 BrowserExtensions/HireflowInteractionTracker/content/tracker.js create mode 100644 BrowserExtensions/HireflowInteractionTracker/icons/icon16.png create mode 100644 BrowserExtensions/HireflowInteractionTracker/icons/icon48.png create mode 100644 BrowserExtensions/HireflowInteractionTracker/icons/icon96.png create mode 100644 BrowserExtensions/HireflowInteractionTracker/manifest.json create mode 100644 BrowserExtensions/HireflowInteractionTracker/popup.css create mode 100644 BrowserExtensions/HireflowInteractionTracker/popup.html create mode 100644 BrowserExtensions/HireflowInteractionTracker/popup.js diff --git a/BrowserExtensions/HireflowInteractionTracker/Archive.zip b/BrowserExtensions/HireflowInteractionTracker/Archive.zip new file mode 100644 index 0000000000000000000000000000000000000000..7e131070bef13b8a032e9191e840fbb069684f8b GIT binary patch literal 6316 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 literal 0 HcmV?d00001 diff --git a/BrowserExtensions/HireflowInteractionTracker/background.js b/BrowserExtensions/HireflowInteractionTracker/background.js new file mode 100644 index 0000000..69577ef --- /dev/null +++ b/BrowserExtensions/HireflowInteractionTracker/background.js @@ -0,0 +1,9 @@ +const STORAGE_KEYS = ["recording", "recordingTabId"]; + +chrome.tabs.onRemoved.addListener((tabId) => { + chrome.storage.local.get(["recording", "recordingTabId"], (data) => { + if (data.recording && data.recordingTabId === tabId) { + chrome.storage.local.remove(STORAGE_KEYS); + } + }); +}); diff --git a/BrowserExtensions/HireflowInteractionTracker/content/tracker.js b/BrowserExtensions/HireflowInteractionTracker/content/tracker.js new file mode 100644 index 0000000..77b1573 --- /dev/null +++ b/BrowserExtensions/HireflowInteractionTracker/content/tracker.js @@ -0,0 +1,317 @@ +(function () { + const GUARD = "__HireflowInteractionTracker"; + if (window[GUARD]) { + return; + } + + const TYPE_DEBOUNCE_MS = 250; + + /** @type {{ recording: boolean, interactions: Array<{action: string, xpath_selector: string, text?: string}>, listenersAttached: boolean, typeTimer: ReturnType | null, pendingType: { xpath: string, text: string } | null, userEdited: WeakSet }} */ + const state = { + recording: false, + interactions: [], + listenersAttached: false, + typeTimer: null, + pendingType: null, + userEdited: new WeakSet(), + }; + window[GUARD] = state; + + /** Input types that represent free-text (or numeric) typing — not clicks-only controls. */ + const TEXT_LIKE_INPUT_TYPES = new Set([ + "text", + "search", + "url", + "tel", + "email", + "password", + "number", + "", + ]); + + function normalizedInputType(el) { + if (el.tagName !== "INPUT") { + return ""; + } + const raw = el.getAttribute("type"); + if (raw == null || raw === "") { + return "text"; + } + return String(raw).toLowerCase(); + } + + function isTextLikeField(el) { + if (!el || el.nodeType !== Node.ELEMENT_NODE) { + return false; + } + if (el.tagName === "TEXTAREA") { + return true; + } + if (el.tagName === "INPUT") { + return TEXT_LIKE_INPUT_TYPES.has(normalizedInputType(el)); + } + if (el.isContentEditable === true) { + return true; + } + return false; + } + + function markFieldUserEdited(el) { + if (isTextLikeField(el)) { + state.userEdited.add(el); + } + } + + function escapeXPathLiteral(str) { + if (str.indexOf("'") === -1) { + return "'" + str + "'"; + } + if (str.indexOf('"') === -1) { + return '"' + str.replace(/"/g, '\\"') + '"'; + } + const parts = str.split("'"); + const concatArgs = []; + for (let i = 0; i < parts.length; i++) { + concatArgs.push("'" + parts[i] + "'"); + if (i < parts.length - 1) { + concatArgs.push('"\'"'); + } + } + return "concat(" + concatArgs.join(", ") + ")"; + } + + function getXPath(element) { + if (!element || element.nodeType !== Node.ELEMENT_NODE) { + return ""; + } + if (element.id) { + return "//*" + "[@id=" + escapeXPathLiteral(element.id) + "]"; + } + + const segments = []; + let node = element; + while (node && node.nodeType === Node.ELEMENT_NODE) { + if (node === document.documentElement) { + segments.unshift("html"); + break; + } + const parent = node.parentElement; + if (!parent) { + break; + } + const tag = node.nodeName.toLowerCase(); + const sameTagSiblings = Array.from(parent.children).filter( + (c) => c.nodeName === node.nodeName + ); + const index = sameTagSiblings.indexOf(node) + 1; + if (sameTagSiblings.length === 1) { + segments.unshift(tag); + } else { + segments.unshift(tag + "[" + index + "]"); + } + node = parent; + } + return "//" + segments.join("/"); + } + + function flushPendingType() { + state.typeTimer = null; + if (!state.pendingType) { + return; + } + const { xpath, text } = state.pendingType; + state.pendingType = null; + const last = state.interactions[state.interactions.length - 1]; + if (last && last.action === "type" && last.xpath_selector === xpath) { + last.text = text; + } else { + state.interactions.push({ + action: "type", + text, + xpath_selector: xpath, + }); + } + } + + function scheduleTypeCommit(xpath, text) { + state.pendingType = { xpath, text }; + if (state.typeTimer) { + clearTimeout(state.typeTimer); + } + state.typeTimer = setTimeout(flushPendingType, TYPE_DEBOUNCE_MS); + } + + function onClickCapture(ev) { + if (!state.recording || !ev.isTrusted) { + return; + } + const target = ev.target; + if (!target || target.nodeType !== Node.ELEMENT_NODE) { + return; + } + flushPendingType(); + state.interactions.push({ + action: "click", + xpath_selector: getXPath(target), + }); + } + + function onBeforeInputCapture(ev) { + if (!state.recording || !ev.isTrusted) { + return; + } + if (typeof InputEvent !== "undefined" && ev instanceof InputEvent) { + const t = ev.target; + if (t && t.nodeType === Node.ELEMENT_NODE && isTextLikeField(t)) { + state.userEdited.add(t); + } + } + } + + function onKeyDownCapture(ev) { + if (!state.recording || !ev.isTrusted) { + return; + } + const el = document.activeElement; + if (!el || !isTextLikeField(el)) { + return; + } + if (ev.ctrlKey || ev.metaKey || ev.altKey) { + if (ev.key === "v" || ev.key === "V" || ev.key === "x" || ev.key === "X") { + markFieldUserEdited(el); + } + return; + } + if ( + ev.key.length === 1 || + ev.key === "Backspace" || + ev.key === "Delete" || + ev.key === "Enter" + ) { + markFieldUserEdited(el); + } + } + + function onPasteCutCapture(ev) { + if (!state.recording || !ev.isTrusted) { + return; + } + const t = ev.target; + if (t && t.nodeType === Node.ELEMENT_NODE) { + markFieldUserEdited(t); + } + } + + function onInputCapture(ev) { + if (!state.recording || !ev.isTrusted) { + return; + } + const target = ev.target; + if (!target || target.nodeType !== Node.ELEMENT_NODE) { + return; + } + if (!isTextLikeField(target)) { + return; + } + if (!state.userEdited.has(target)) { + return; + } + let text = ""; + if (target.isContentEditable) { + text = target.innerText || target.textContent || ""; + } else { + text = target.value != null ? String(target.value) : ""; + } + const xpath = getXPath(target); + scheduleTypeCommit(xpath, text); + } + + function onChangeCapture(ev) { + if (!state.recording || !ev.isTrusted) { + return; + } + const target = ev.target; + if (!target || target.nodeType !== Node.ELEMENT_NODE) { + return; + } + if (target.tagName !== "SELECT") { + return; + } + flushPendingType(); + const text = target.value != null ? String(target.value) : ""; + state.interactions.push({ + action: "select", + text, + xpath_selector: getXPath(target), + }); + } + + function attachListeners() { + if (state.listenersAttached) { + return; + } + document.addEventListener("click", onClickCapture, true); + document.addEventListener("beforeinput", onBeforeInputCapture, true); + document.addEventListener("keydown", onKeyDownCapture, true); + document.addEventListener("paste", onPasteCutCapture, true); + document.addEventListener("cut", onPasteCutCapture, true); + document.addEventListener("input", onInputCapture, true); + document.addEventListener("change", onChangeCapture, true); + state.listenersAttached = true; + } + + function detachListeners() { + if (!state.listenersAttached) { + return; + } + document.removeEventListener("click", onClickCapture, true); + document.removeEventListener("beforeinput", onBeforeInputCapture, true); + document.removeEventListener("keydown", onKeyDownCapture, true); + document.removeEventListener("paste", onPasteCutCapture, true); + document.removeEventListener("cut", onPasteCutCapture, true); + document.removeEventListener("input", onInputCapture, true); + document.removeEventListener("change", onChangeCapture, true); + state.listenersAttached = false; + } + + chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message.type === "START_RECORDING") { + state.interactions = []; + state.pendingType = null; + state.userEdited = new WeakSet(); + if (state.typeTimer) { + clearTimeout(state.typeTimer); + state.typeTimer = null; + } + state.recording = true; + attachListeners(); + sendResponse({ ok: true }); + return false; + } + + if (message.type === "STOP_AND_GET_INTERACTIONS") { + state.recording = false; + if (state.typeTimer) { + clearTimeout(state.typeTimer); + state.typeTimer = null; + } + flushPendingType(); + detachListeners(); + const interactions = state.interactions.slice(); + state.interactions = []; + sendResponse({ ok: true, interactions }); + return false; + } + + if (message.type === "GET_STATUS") { + sendResponse({ + ok: true, + recording: state.recording, + count: state.interactions.length, + }); + return false; + } + + return false; + }); +})(); diff --git a/BrowserExtensions/HireflowInteractionTracker/icons/icon16.png b/BrowserExtensions/HireflowInteractionTracker/icons/icon16.png new file mode 100644 index 0000000000000000000000000000000000000000..a2d86ab22cce84215cc300813df23ea081b4c1bf GIT binary patch literal 82 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`lAbP(Ar-fh1AOcL&KEjkkab`M e<6<@*ab||#sv`LsCl()|A_h-aKbLh*2~7ao7Zfu9 literal 0 HcmV?d00001 diff --git a/BrowserExtensions/HireflowInteractionTracker/icons/icon48.png b/BrowserExtensions/HireflowInteractionTracker/icons/icon48.png new file mode 100644 index 0000000000000000000000000000000000000000..91d82f32a3f3fef26b1571722c571f28d157cb26 GIT binary patch literal 125 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC7f%<*kcwMx&pQe-Ft9Kfq^bzV rb7=k8bvv0+EYfy`njxgN@xNAXmu$w literal 0 HcmV?d00001 diff --git a/BrowserExtensions/HireflowInteractionTracker/icons/icon96.png b/BrowserExtensions/HireflowInteractionTracker/icons/icon96.png new file mode 100644 index 0000000000000000000000000000000000000000..4a70ad0baf96ac4a2ead291e4336ecdd58653b51 GIT binary patch literal 216 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGo^F3W0Ln>~)y=chGz`$|9;LHM^ zpB)U6Doc*@teJRq@$N$`3=Av`4Gatd3=B*R4nQ>w42%p4lnF8%AkNeS?P4FCcr}Cm S@Vo~)mBG{1&t;ucLK6Ulh$B1z literal 0 HcmV?d00001 diff --git a/BrowserExtensions/HireflowInteractionTracker/manifest.json b/BrowserExtensions/HireflowInteractionTracker/manifest.json new file mode 100644 index 0000000..8cd4f25 --- /dev/null +++ b/BrowserExtensions/HireflowInteractionTracker/manifest.json @@ -0,0 +1,30 @@ +{ + "manifest_version": 3, + "name": "Hireflow Interaction Tracker", + "version": "1.0.0", + "description": "Record page interactions (clicks, typing) and export as JSON with XPath selectors.", + "permissions": ["activeTab", "scripting", "storage"], + "background": { + "service_worker": "background.js" + }, + "action": { + "default_popup": "popup.html", + "default_title": "Hireflow Interaction Tracker", + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "96": "icons/icon96.png" + } + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "96": "icons/icon96.png" + }, + "browser_specific_settings": { + "gecko": { + "id": "hireflow-interaction-tracker@hireflow.local", + "strict_min_version": "109.0" + } + } +} diff --git a/BrowserExtensions/HireflowInteractionTracker/popup.css b/BrowserExtensions/HireflowInteractionTracker/popup.css new file mode 100644 index 0000000..37d4ff9 --- /dev/null +++ b/BrowserExtensions/HireflowInteractionTracker/popup.css @@ -0,0 +1,119 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + font-size: 14px; + color: #1a1a1a; + background: #f4f4f5; +} + +.panel { + padding: 14px 16px 16px; +} + +.title { + margin: 0 0 8px; + font-size: 16px; + font-weight: 600; +} + +.hint { + margin: 0 0 8px; + line-height: 1.45; + color: #52525b; +} + +.nav-hint { + margin: 0 0 12px; + font-size: 12px; + color: #71717a; +} + +.nav-hint.hidden, +.hidden { + display: none !important; +} + +.actions { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +.btn { + flex: 1; + padding: 8px 12px; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: #2563eb; + color: #fff; +} + +.btn-primary:hover:not(:disabled) { + background: #1d4ed8; +} + +.btn-danger { + background: #dc2626; + color: #fff; +} + +.btn-danger:hover:not(:disabled) { + background: #b91c1c; +} + +.btn-secondary { + flex: 0 0 auto; + margin-top: 8px; + background: #e4e4e7; + color: #18181b; +} + +.btn-secondary:hover:not(:disabled) { + background: #d4d4d8; +} + +.export { + margin-top: 4px; +} + +.export-label { + display: block; + margin-bottom: 6px; + font-weight: 500; + font-size: 13px; +} + +#export-json { + width: 100%; + padding: 10px; + border: 1px solid #d4d4d8; + border-radius: 6px; + font-family: ui-monospace, "Cascadia Code", monospace; + font-size: 12px; + line-height: 1.4; + resize: vertical; + background: #fff; +} + +.copy-status { + min-height: 1.2em; + margin: 6px 0 0; + font-size: 12px; + color: #16a34a; +} diff --git a/BrowserExtensions/HireflowInteractionTracker/popup.html b/BrowserExtensions/HireflowInteractionTracker/popup.html new file mode 100644 index 0000000..20b8db7 --- /dev/null +++ b/BrowserExtensions/HireflowInteractionTracker/popup.html @@ -0,0 +1,33 @@ + + + + + + Hireflow Interaction Tracker + + + +
+

Interacties

+

+ Open een webpagina en druk op Opnemen. Na Stop kun je de JSON kopiëren. +

+ +
+ + +
+ +
+ + + diff --git a/BrowserExtensions/HireflowInteractionTracker/popup.js b/BrowserExtensions/HireflowInteractionTracker/popup.js new file mode 100644 index 0000000..95a3290 --- /dev/null +++ b/BrowserExtensions/HireflowInteractionTracker/popup.js @@ -0,0 +1,216 @@ +const TRACKER_FILE = "content/tracker.js"; + +function storageGet(keys) { + return new Promise((resolve) => { + chrome.storage.local.get(keys, resolve); + }); +} + +function storageSet(obj) { + return new Promise((resolve) => { + chrome.storage.local.set(obj, resolve); + }); +} + +function storageRemove(keys) { + return new Promise((resolve) => { + chrome.storage.local.remove(keys, resolve); + }); +} + +function tabsQuery(queryInfo) { + return new Promise((resolve) => { + chrome.tabs.query(queryInfo, resolve); + }); +} + +function executeScript(details) { + return new Promise((resolve, reject) => { + chrome.scripting.executeScript(details, () => { + const err = chrome.runtime.lastError; + if (err) { + reject(new Error(err.message)); + return; + } + resolve(); + }); + }); +} + +const els = { + record: document.getElementById("record"), + stop: document.getElementById("stop"), + exportSection: document.getElementById("export-section"), + exportJson: document.getElementById("export-json"), + copy: document.getElementById("copy"), + copyStatus: document.getElementById("copy-status"), + hint: document.getElementById("hint"), + navHint: document.getElementById("nav-hint"), +}; + +function isRestrictedUrl(url) { + if (!url) { + return true; + } + const u = url.toLowerCase(); + return ( + u.startsWith("chrome://") || + u.startsWith("chrome-extension://") || + u.startsWith("edge://") || + u.startsWith("about:") || + u.startsWith("moz-extension://") || + u.startsWith("devtools:") || + u.startsWith("view-source:") + ); +} + +async function getActiveTab() { + const tabs = await tabsQuery({ active: true, currentWindow: true }); + return tabs[0]; +} + +async function injectTracker(tabId) { + await executeScript({ + target: { tabId }, + files: [TRACKER_FILE], + }); +} + +function sendToTab(tabId, message) { + return new Promise((resolve, reject) => { + chrome.tabs.sendMessage(tabId, message, (response) => { + const err = chrome.runtime.lastError; + if (err) { + reject(new Error(err.message)); + return; + } + resolve(response); + }); + }); +} + +async function clearRecordingStorage() { + await storageRemove(["recording", "recordingTabId"]); +} + +function setRecordingUi(isRecording, showExport) { + els.record.classList.toggle("hidden", isRecording); + els.stop.classList.toggle("hidden", !isRecording); + els.stop.disabled = !isRecording; + els.navHint.classList.toggle("hidden", !isRecording); + els.exportSection.classList.toggle("hidden", !showExport); +} + +async function syncStateFromTab() { + const tab = await getActiveTab(); + const data = await storageGet(["recording", "recordingTabId"]); + + if (!tab?.id) { + setRecordingUi(false, !!els.exportJson.value); + return; + } + + if (!data.recording || data.recordingTabId !== tab.id) { + setRecordingUi(false, !!els.exportJson.value); + return; + } + + if (isRestrictedUrl(tab.url)) { + await clearRecordingStorage(); + setRecordingUi(false, !!els.exportJson.value); + return; + } + + try { + await injectTracker(tab.id); + const status = await sendToTab(tab.id, { type: "GET_STATUS" }); + if (status && status.recording) { + setRecordingUi(true, !!els.exportJson.value); + } else { + await clearRecordingStorage(); + setRecordingUi(false, !!els.exportJson.value); + } + } catch { + await clearRecordingStorage(); + setRecordingUi(false, !!els.exportJson.value); + } +} + +els.record.addEventListener("click", async () => { + els.copyStatus.textContent = ""; + const tab = await getActiveTab(); + if (!tab?.id) { + return; + } + if (isRestrictedUrl(tab.url)) { + els.hint.textContent = + "Opnemen werkt niet op deze pagina (browser-interne URL). Open een normale website."; + return; + } + + try { + await injectTracker(tab.id); + await sendToTab(tab.id, { type: "START_RECORDING" }); + await storageSet({ + recording: true, + recordingTabId: tab.id, + }); + els.exportJson.value = ""; + setRecordingUi(true, false); + els.hint.textContent = + "Interacties worden vastgelegd. Druk op Stop om de JSON te tonen."; + } catch (e) { + els.hint.textContent = + "Kon niet starten: ververs de pagina of controleer of de site scripts toestaat."; + console.error(e); + } +}); + +els.stop.addEventListener("click", async () => { + els.copyStatus.textContent = ""; + const tab = await getActiveTab(); + const data = await storageGet(["recordingTabId"]); + + if (!tab?.id || tab.id !== data.recordingTabId) { + await clearRecordingStorage(); + setRecordingUi(false, false); + els.hint.textContent = "Geen actieve opname op dit tabblad."; + return; + } + + try { + await injectTracker(tab.id); + const response = await sendToTab(tab.id, { + type: "STOP_AND_GET_INTERACTIONS", + }); + await clearRecordingStorage(); + + const interactions = response?.interactions ?? []; + els.exportJson.value = JSON.stringify(interactions, null, 2); + setRecordingUi(false, true); + els.hint.textContent = + "JSON hieronder. Gebruik Kopiëren of selecteer de tekst handmatig."; + } catch (e) { + await clearRecordingStorage(); + setRecordingUi(false, false); + els.hint.textContent = + "Stoppen mislukt (pagina mogelijk vernieuwd). Start opnieuw met Opnemen."; + console.error(e); + } +}); + +els.copy.addEventListener("click", async () => { + const text = els.exportJson.value; + if (!text) { + return; + } + try { + await navigator.clipboard.writeText(text); + els.copyStatus.textContent = "Gekopieerd naar klembord."; + } catch { + els.exportJson.select(); + els.copyStatus.textContent = "Selecteer Cmd/Ctrl+C om te kopiëren."; + } +}); + +syncStateFromTab();