1139 lines
44 KiB
JavaScript
1139 lines
44 KiB
JavaScript
// content.js — LinkedIn page scraper + overlay launcher
|
||
|
||
(function () {
|
||
'use strict';
|
||
|
||
// Prevent double injection
|
||
if (window.__linkedinAnalyzerLoaded) return;
|
||
window.__linkedinAnalyzerLoaded = true;
|
||
|
||
// ─── Job Data Extraction ─────────────────────────────────────────────────
|
||
|
||
function extractJobData() {
|
||
const data = {
|
||
jobTitle: '',
|
||
company: '',
|
||
location: '',
|
||
jobType: '',
|
||
seniority: '',
|
||
description: '',
|
||
skills: [],
|
||
questions: [],
|
||
url: window.location.href,
|
||
detectedAt: Date.now()
|
||
};
|
||
|
||
// ── Helper: try selectors until one works
|
||
function trySelectors(selectors, minLength = 1) {
|
||
for (const sel of selectors) {
|
||
try {
|
||
const el = document.querySelector(sel);
|
||
if (el && el.textContent.trim().length >= minLength) {
|
||
return el.textContent.trim();
|
||
}
|
||
} catch(e) { /* skip invalid selectors */ }
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function trySelectorsInner(selectors, minLength = 1) {
|
||
for (const sel of selectors) {
|
||
try {
|
||
const el = document.querySelector(sel);
|
||
if (el && el.innerText.trim().length >= minLength) {
|
||
return el.innerText.trim();
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
// LinkedIn's job panel is full of text that is neither navigation nor the
|
||
// job title: screen-reader-only headings ("0 notifications"), and product
|
||
// furniture inside the detail column itself ("Are these results helpful?",
|
||
// "Your profile and resume match...", "Get personalized tips..."). A
|
||
// heuristic DOM scan cannot reliably tell those from a job title, so the
|
||
// scan is now the LAST resort rather than the second.
|
||
function isVisible(el) {
|
||
if (!el || !el.offsetParent) return false;
|
||
if (el.closest('[aria-hidden="true"]')) return false;
|
||
const cls = el.className && el.className.baseVal !== undefined
|
||
? el.className.baseVal
|
||
: String(el.className || '');
|
||
return !/visually-hidden|sr-only|a11y-text/.test(cls);
|
||
}
|
||
|
||
const NAV_NOISE = new RegExp([
|
||
// chrome / navigation
|
||
'notification', 'message', 'invitation', 'my network', 'feed update',
|
||
'premium', 'search', 'similar', 'featured', 'recommended',
|
||
// in-panel product furniture — the class of string that broke this before
|
||
'helpful', 'results', 'feedback', 'your profile', 'match', 'tips',
|
||
'people you', 'about the job', 'sign in', 'join now', 'people also',
|
||
'jobs based on', 'reach out',
|
||
// counters
|
||
'^\\d+\\s'
|
||
].join('|'), 'i');
|
||
|
||
function plausibleTitle(text) {
|
||
return text.length > 4 && text.length < 100 && !NAV_NOISE.test(text);
|
||
}
|
||
|
||
// 1. Most Reliable Method: Extract from active job link using currentJobId
|
||
let currentJobId = new URLSearchParams(window.location.search).get('currentJobId');
|
||
if (!currentJobId) {
|
||
const match = window.location.pathname.match(/\/jobs\/view\/(\d+)/);
|
||
if (match) currentJobId = match[1];
|
||
}
|
||
|
||
if (currentJobId) {
|
||
// Find all links containing this job ID
|
||
const links = document.querySelectorAll(`a[href*="/jobs/view/${currentJobId}"]`);
|
||
for (const link of links) {
|
||
const text = (link.textContent || '').trim();
|
||
// Filter out empty or obviously wrong texts (like "Save", "Easy Apply")
|
||
if (text && plausibleTitle(text) && !text.includes('Easy Apply') && !text.includes('Save')) {
|
||
data.jobTitle = text;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. Fallback: Try known selectors
|
||
if (!data.jobTitle) {
|
||
data.jobTitle = trySelectors([
|
||
'.job-details-jobs-unified-top-card__job-title h1 a',
|
||
'.job-details-jobs-unified-top-card__job-title h1',
|
||
'.jobs-unified-top-card__job-title h1',
|
||
'.jobs-unified-top-card__job-title',
|
||
'.job-details-jobs-unified-top-card__job-title',
|
||
'h2.job-details-jobs-unified-top-card__job-title',
|
||
'.jobs-unified-top-card__job-title a',
|
||
'h1.t-24',
|
||
'h1.t-18',
|
||
'h1[class*="job-title"]',
|
||
'h2[class*="job-title"]',
|
||
'.t-24.t-bold',
|
||
'.top-card-layout__title',
|
||
'a[class*="topcard__title"]',
|
||
'.topcard__title',
|
||
]);
|
||
|
||
// If we grabbed garbage, reset it
|
||
if (data.jobTitle && !plausibleTitle(data.jobTitle)) {
|
||
data.jobTitle = '';
|
||
}
|
||
}
|
||
|
||
// 3. Last Resort Fallback: Browser tab title
|
||
if (!data.jobTitle) {
|
||
const m = document.title.match(/^(?:\(\d+\+?\)\s*)?(.+?)\s*[|·]/);
|
||
if (m) {
|
||
const candidate = m[1].trim();
|
||
if (plausibleTitle(candidate) && !/^linkedin$/i.test(candidate)) {
|
||
data.jobTitle = candidate;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Last resort: a heading inside the job detail panel only — never page-wide.
|
||
if (!data.jobTitle) {
|
||
const jobPanel = document.querySelector(
|
||
'.scaffold-layout__detail-column, .jobs-search__job-details, .job-view-layout, ' +
|
||
'.jobs-details, [class*="jobs-unified-top-card"], main'
|
||
);
|
||
if (jobPanel) {
|
||
// h1 first: the job title is a level-1 heading, while the cards that
|
||
// polluted this before ("Are these results helpful?") are h2/h3.
|
||
for (const h of [...jobPanel.querySelectorAll('h1'), ...jobPanel.querySelectorAll('h2')]) {
|
||
const text = h.textContent.trim();
|
||
if (isVisible(h) && plausibleTitle(text)) {
|
||
data.jobTitle = text;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Company Name
|
||
data.company = trySelectors([
|
||
'.job-details-jobs-unified-top-card__company-name a',
|
||
'.job-details-jobs-unified-top-card__company-name',
|
||
'.jobs-unified-top-card__company-name a',
|
||
'.jobs-unified-top-card__company-name',
|
||
'.topcard__org-name-link',
|
||
'.top-card-layout__card-subtitle a',
|
||
'.top-card-layout__second-subline a',
|
||
'a[data-tracking-control-name*="company"]',
|
||
'a[href*="/company/"]',
|
||
'.jobs-poster__name',
|
||
]);
|
||
|
||
// Fallback: look for company link in job details panel
|
||
if (!data.company) {
|
||
const jobPanel = document.querySelector('.scaffold-layout__detail-column, .jobs-search__job-details, .job-view-layout');
|
||
if (jobPanel) {
|
||
const companyLink = jobPanel.querySelector('a[href*="/company/"]');
|
||
if (companyLink) data.company = companyLink.textContent.trim();
|
||
}
|
||
}
|
||
|
||
// ── Location + metadata
|
||
const metaText = trySelectors([
|
||
'.job-details-jobs-unified-top-card__primary-description-container',
|
||
'.job-details-jobs-unified-top-card__primary-description',
|
||
'.jobs-unified-top-card__primary-description',
|
||
'.jobs-unified-top-card__subtitle',
|
||
'.top-card-layout__card-subtitle',
|
||
'.topcard__flavor--bullet',
|
||
]);
|
||
if (metaText) {
|
||
const parts = metaText.split('·').map(p => p.trim()).filter(Boolean);
|
||
if (parts.length > 0) data.location = parts[0];
|
||
}
|
||
|
||
// ── Job Type & Seniority from pills/tags
|
||
const tagSelectors = [
|
||
'.job-details-jobs-unified-top-card__job-insight span',
|
||
'.jobs-unified-top-card__job-insight span',
|
||
'.ui-label',
|
||
'.job-criteria__text',
|
||
'li.job-criteria__item',
|
||
];
|
||
for (const sel of tagSelectors) {
|
||
document.querySelectorAll(sel).forEach(el => {
|
||
const text = el.textContent.trim();
|
||
if (text.match(/full.time|part.time|contract|freelance|internship|temporary/i)) data.jobType = text;
|
||
if (text.match(/entry|associate|mid.senior|senior|director|executive|manager|intern/i)) data.seniority = text;
|
||
});
|
||
}
|
||
|
||
// Fallback: look for pill-style elements near the top
|
||
if (!data.jobType) {
|
||
document.querySelectorAll('span, li').forEach(el => {
|
||
const text = el.textContent.trim();
|
||
if (text === 'Full-time' || text === 'Part-time' || text === 'Contract' || text === 'Hybrid' ||
|
||
text === 'On-site' || text === 'Remote') {
|
||
if (!data.jobType) data.jobType = text;
|
||
if (!data.location && (text === 'Hybrid' || text === 'On-site' || text === 'Remote')) {
|
||
data.jobType = text;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── Job Description (many fallbacks)
|
||
data.description = trySelectorsInner([
|
||
'.jobs-description__content .jobs-box__html-content',
|
||
'#job-details',
|
||
'.jobs-description-content__text',
|
||
'.jobs-description__content',
|
||
'.jobs-description',
|
||
'.job-view-layout .description__text',
|
||
'.description__text--rich',
|
||
'.show-more-less-html__markup',
|
||
'[class*="jobs-description"]',
|
||
'[class*="description__text"]',
|
||
], 50);
|
||
|
||
// Fallback: find "About the job" section and grab everything after it
|
||
if (!data.description || data.description.length < 50) {
|
||
const allSections = document.querySelectorAll('section, div, article');
|
||
for (const section of allSections) {
|
||
const header = section.querySelector('h2, h3');
|
||
if (header && header.textContent.trim().match(/about the job|job description|description|responsibilities/i)) {
|
||
const text = section.innerText.trim();
|
||
if (text.length > 100) {
|
||
data.description = text.substring(0, 6000);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Last resort: grab largest text block from the detail column
|
||
if (!data.description || data.description.length < 50) {
|
||
const detailCol = document.querySelector('.scaffold-layout__detail-column, .jobs-search__job-details, .job-view-layout, main');
|
||
if (detailCol) {
|
||
let longestText = '';
|
||
detailCol.querySelectorAll('div, section, article').forEach(el => {
|
||
const text = el.innerText.trim();
|
||
if (text.length > longestText.length && text.length > 200) {
|
||
longestText = text;
|
||
}
|
||
});
|
||
if (longestText) {
|
||
data.description = longestText.substring(0, 6000);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Cap description
|
||
if (data.description) data.description = data.description.substring(0, 6000);
|
||
|
||
// ── Skills
|
||
const skillSelectors = [
|
||
'.job-details-how-you-match__skills-item-subtitle',
|
||
'.job-details-skill-match-status-list li',
|
||
'.jobs-pref-match-skill-wrapper',
|
||
'.job-details-how-you-match-card__pill',
|
||
'.job-details-skill-match-status-list span',
|
||
];
|
||
const skillSet = new Set();
|
||
for (const sel of skillSelectors) {
|
||
document.querySelectorAll(sel).forEach(el => {
|
||
const skill = el.textContent.trim();
|
||
if (skill && skill.length < 60) skillSet.add(skill);
|
||
});
|
||
}
|
||
data.skills = Array.from(skillSet).slice(0, 20);
|
||
|
||
// ── Application Questions (Easy Apply)
|
||
data.questions = extractApplicationQuestions();
|
||
|
||
return data;
|
||
}
|
||
|
||
function extractApplicationQuestions() {
|
||
const questions = [];
|
||
const seen = new Set();
|
||
|
||
// LinkedIn wraps each form field in .fb-dash-form-element or [data-test-form-element]
|
||
// Search the whole document to avoid getting trapped in the wrong dialog (like the messaging pane).
|
||
const formElements = document.querySelectorAll(
|
||
'.jobs-easy-apply-modal .fb-dash-form-element, .jobs-easy-apply-modal [data-test-form-element], .fb-dash-form-element, [data-test-form-element]'
|
||
);
|
||
|
||
formElements.forEach(el => {
|
||
const label = el.querySelector('label');
|
||
if (!label) return;
|
||
|
||
// Prefer the aria-hidden span text to avoid duplication from visually-hidden span
|
||
const ariaSpan = label.querySelector('span[aria-hidden="true"]');
|
||
let text = (ariaSpan ? ariaSpan.textContent : label.textContent)
|
||
.replace(/\*/g, '')
|
||
.replace(/required/gi, '')
|
||
.replace(/\n/g, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
|
||
if (!text || text.length < 5 || text.length > 300 || seen.has(text.toLowerCase())) return;
|
||
seen.add(text.toLowerCase());
|
||
|
||
// Detect input type from this form element
|
||
let inputType = 'text';
|
||
if (el.querySelector('select')) inputType = 'select';
|
||
else if (el.querySelector('input[type="radio"]')) inputType = 'radio';
|
||
else if (el.querySelector('textarea')) inputType = 'textarea';
|
||
else if (el.querySelector('input[type="checkbox"]')) inputType = 'checkbox';
|
||
|
||
questions.push({ question: text, type: inputType });
|
||
});
|
||
|
||
// Fallback: if the precise approach found nothing, scan broader inside modals
|
||
if (questions.length === 0) {
|
||
const activeModals = document.querySelectorAll('.jobs-easy-apply-modal, .artdeco-modal, [role="dialog"]');
|
||
activeModals.forEach(modal => {
|
||
const allLabels = modal.querySelectorAll('label');
|
||
allLabels.forEach(label => {
|
||
const ariaSpan = label.querySelector('span[aria-hidden="true"]');
|
||
let text = (ariaSpan ? ariaSpan.textContent : label.textContent)
|
||
.replace(/\*/g, '').replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
|
||
|
||
if (!text || text.length < 8 || text.length > 300 || seen.has(text.toLowerCase())) return;
|
||
|
||
const looksLikeQuestion = (
|
||
text.endsWith('?') ||
|
||
/^(how many|what|do you|are you|have you|will you|describe|tell us)/i.test(text) ||
|
||
/salary|experience|education|degree|visa|relocat|notice|certif/i.test(text)
|
||
);
|
||
if (!looksLikeQuestion) return;
|
||
|
||
seen.add(text.toLowerCase());
|
||
questions.push({ question: text, type: 'text' });
|
||
});
|
||
});
|
||
}
|
||
|
||
console.log('[LJA] Scraped questions:', questions);
|
||
return questions.slice(0, 15);
|
||
}
|
||
|
||
// ─── Overlay Injection ───────────────────────────────────────────────────
|
||
|
||
function injectOverlay() {
|
||
if (document.getElementById('lja-root')) return;
|
||
|
||
const root = document.createElement('div');
|
||
root.id = 'lja-root';
|
||
document.body.appendChild(root);
|
||
|
||
const jobData = extractJobData();
|
||
root.innerHTML = buildOverlayHTML(jobData);
|
||
|
||
initOverlayLogic(root, jobData);
|
||
}
|
||
|
||
function buildOverlayHTML(job) {
|
||
const hasJob = !!(job.jobTitle || job.description);
|
||
return `
|
||
<div id="lja-toggle" title="LinkedIn Job Analyzer">🔍</div>
|
||
|
||
<div id="lja-panel">
|
||
<div id="lja-header">
|
||
<div id="lja-title">
|
||
<span>🔍</span>
|
||
<div>
|
||
<div id="lja-panel-name">Job Analyzer</div>
|
||
<div id="lja-status-bar">
|
||
<span id="lja-status-dot"></span>
|
||
<span id="lja-status-text">${hasJob ? 'Job detected' : 'No job detected — use manual mode'}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="lja-header-actions">
|
||
<button id="lja-minimize" title="Minimize">−</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Job preview -->
|
||
<div id="lja-job-preview">
|
||
<div id="lja-job-title">${job.jobTitle || 'Unknown Position'}</div>
|
||
<div id="lja-job-company">${job.company || ''}${job.location ? ' · ' + job.location : ''}</div>
|
||
${job.skills.length ? `<div id="lja-skills">${job.skills.slice(0, 6).map(s => `<span class="lja-skill-tag">${s}</span>`).join('')}</div>` : ''}
|
||
</div>
|
||
|
||
<!-- Manual mode -->
|
||
<div id="lja-manual-section">
|
||
<div id="lja-manual-toggle">📋 Paste job description manually</div>
|
||
<textarea id="lja-manual-input" placeholder="Paste the full job description here..."></textarea>
|
||
<button id="lja-use-manual" class="lja-btn-secondary">Use this text</button>
|
||
</div>
|
||
|
||
<!-- Tabs -->
|
||
<div id="lja-tabs">
|
||
<button class="lja-tab active" data-tab="analysis">📊</button>
|
||
<button class="lja-tab" data-tab="ats">🎯</button>
|
||
<button class="lja-tab" data-tab="coverletter">✉️</button>
|
||
<button class="lja-tab" data-tab="cvtips">📝</button>
|
||
<button class="lja-tab" data-tab="qa">❓</button>
|
||
<button class="lja-tab" data-tab="benefits">⭐</button>
|
||
</div>
|
||
|
||
<div id="lja-tab-labels">
|
||
<span class="lja-tab-label active" data-tab="analysis">Analysis</span>
|
||
<span class="lja-tab-label" data-tab="ats">ATS Score</span>
|
||
<span class="lja-tab-label" data-tab="coverletter">Cover Letter</span>
|
||
<span class="lja-tab-label" data-tab="cvtips">CV Tips</span>
|
||
<span class="lja-tab-label" data-tab="qa">Q&A</span>
|
||
<span class="lja-tab-label" data-tab="benefits">Benefits</span>
|
||
</div>
|
||
|
||
<!-- Tab panes -->
|
||
<div id="lja-content">
|
||
${['analysis', 'ats', 'coverletter', 'cvtips', 'qa', 'benefits'].map(tab => `
|
||
<div class="lja-pane" id="lja-pane-${tab}" ${tab !== 'analysis' ? 'style="display:none"' : ''}>
|
||
<div class="lja-pane-empty">
|
||
<div class="lja-empty-icon">${tabIcon(tab)}</div>
|
||
<div class="lja-empty-text">Click <strong>Analyze</strong> to generate</div>
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
|
||
<!-- Actions -->
|
||
<div id="lja-actions" style="display: flex; gap: 8px;">
|
||
<button id="lja-pdf-btn" style="background: #1a237e; border: 1px solid #3949ab; color: white; padding: 10px; border-radius: 6px; font-weight: 600; cursor: pointer; flex: 1; transition: 0.2s;">📥 Get ATS PDF</button>
|
||
<button id="lja-analyze-btn" style="flex: 1.5;">⚡ Analyze Job</button>
|
||
<button id="lja-copy-btn" style="display:none; flex: 0.5;">📋 Copy</button>
|
||
</div>
|
||
|
||
<!-- Loading -->
|
||
<div id="lja-loading" style="display:none">
|
||
<div class="lja-spinner"></div>
|
||
<div id="lja-loading-text">Analyzing...</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function tabIcon(tab) {
|
||
const icons = { analysis: '📊', ats: '🎯', coverletter: '✉️', cvtips: '📝', qa: '❓', benefits: '⭐' };
|
||
return icons[tab] || '🔍';
|
||
}
|
||
|
||
// ─── Overlay Logic ───────────────────────────────────────────────────────
|
||
|
||
function initOverlayLogic(root, jobData) {
|
||
let currentTab = 'analysis';
|
||
let isOpen = false;
|
||
let isDragging = false;
|
||
let dragStartX, dragStartY, panelStartX, panelStartY;
|
||
const results = {};
|
||
|
||
const toggle = root.querySelector('#lja-toggle');
|
||
const panel = root.querySelector('#lja-panel');
|
||
const analyzeBtn = root.querySelector('#lja-analyze-btn');
|
||
const copyBtn = root.querySelector('#lja-copy-btn');
|
||
const loading = root.querySelector('#lja-loading');
|
||
const loadingText = root.querySelector('#lja-loading-text');
|
||
const minimizeBtn = root.querySelector('#lja-minimize');
|
||
const manualToggle = root.querySelector('#lja-manual-toggle');
|
||
const manualSection = root.querySelector('#lja-manual-section');
|
||
const manualInput = root.querySelector('#lja-manual-input');
|
||
const useManualBtn = root.querySelector('#lja-use-manual');
|
||
|
||
// Status dot colour
|
||
const statusDot = root.querySelector('#lja-status-dot');
|
||
statusDot.style.background = jobData.jobTitle ? '#00d67e' : '#ffb347';
|
||
|
||
// ── Toggle panel open/close
|
||
toggle.addEventListener('click', () => {
|
||
isOpen = !isOpen;
|
||
panel.style.display = isOpen ? 'flex' : 'none';
|
||
toggle.style.display = isOpen ? 'none' : 'flex';
|
||
});
|
||
|
||
minimizeBtn.addEventListener('click', () => {
|
||
isOpen = false;
|
||
panel.style.display = 'none';
|
||
toggle.style.display = 'flex';
|
||
});
|
||
|
||
// ── Drag panel
|
||
const header = root.querySelector('#lja-header');
|
||
header.addEventListener('mousedown', (e) => {
|
||
if (e.target.tagName === 'BUTTON') return;
|
||
isDragging = true;
|
||
const rect = panel.getBoundingClientRect();
|
||
dragStartX = e.clientX;
|
||
dragStartY = e.clientY;
|
||
panelStartX = rect.right;
|
||
panelStartY = rect.bottom;
|
||
e.preventDefault();
|
||
});
|
||
|
||
document.addEventListener('mousemove', (e) => {
|
||
if (!isDragging) return;
|
||
const dx = dragStartX - e.clientX;
|
||
const dy = dragStartY - e.clientY;
|
||
const newRight = Math.max(8, Math.min(window.innerWidth - 200, panelStartX + dx));
|
||
const newBottom = Math.max(8, Math.min(window.innerHeight - 100, panelStartY + dy));
|
||
panel.style.right = newRight + 'px';
|
||
panel.style.bottom = newBottom + 'px';
|
||
});
|
||
|
||
document.addEventListener('mouseup', () => { isDragging = false; });
|
||
|
||
// ── Manual toggle
|
||
manualToggle.addEventListener('click', () => {
|
||
const expanded = manualInput.style.display === 'block';
|
||
manualInput.style.display = expanded ? 'none' : 'block';
|
||
useManualBtn.style.display = expanded ? 'none' : 'block';
|
||
});
|
||
|
||
useManualBtn.addEventListener('click', () => {
|
||
const text = manualInput.value.trim();
|
||
if (text.length < 50) {
|
||
showPanelToast(root, 'Please paste a longer job description');
|
||
return;
|
||
}
|
||
jobData.description = text;
|
||
jobData.jobTitle = jobData.jobTitle || 'Pasted Job';
|
||
root.querySelector('#lja-job-title').textContent = jobData.jobTitle;
|
||
root.querySelector('#lja-status-text').textContent = 'Manual mode — ready to analyze';
|
||
statusDot.style.background = '#6c63ff';
|
||
showPanelToast(root, '✅ Manual text applied');
|
||
});
|
||
|
||
// ── Tab switching
|
||
root.querySelectorAll('.lja-tab').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
currentTab = btn.dataset.tab;
|
||
root.querySelectorAll('.lja-tab').forEach(b => b.classList.remove('active'));
|
||
root.querySelectorAll('.lja-tab-label').forEach(b => b.classList.remove('active'));
|
||
root.querySelectorAll('.lja-pane').forEach(p => p.style.display = 'none');
|
||
btn.classList.add('active');
|
||
root.querySelector(`.lja-tab-label[data-tab="${currentTab}"]`).classList.add('active');
|
||
root.querySelector(`#lja-pane-${currentTab}`).style.display = 'block';
|
||
|
||
// Show copy button if we have content
|
||
copyBtn.style.display = results[currentTab] ? 'block' : 'none';
|
||
});
|
||
});
|
||
|
||
// ── Analyze button
|
||
analyzeBtn.addEventListener('click', async () => {
|
||
if (!jobData.description && !jobData.jobTitle) {
|
||
showPanelToast(root, '⚠️ No job detected. Use manual mode.');
|
||
return;
|
||
}
|
||
|
||
const settings = await getSettings();
|
||
if (!settings.apiKey) {
|
||
showPanelToast(root, '⚠️ Set your API key in extension settings first!');
|
||
return;
|
||
}
|
||
|
||
await runAnalysis(settings, jobData, currentTab, results, root, loading, loadingText, copyBtn);
|
||
});
|
||
|
||
// ── Generate PDF Action
|
||
const pdfBtn = root.querySelector('#lja-pdf-btn');
|
||
if (pdfBtn) {
|
||
pdfBtn.addEventListener('click', async () => {
|
||
if (!jobData.description && !jobData.jobTitle) {
|
||
showPanelToast(root, '⚠️ No job detected.');
|
||
return;
|
||
}
|
||
|
||
const settings = await getSettings();
|
||
if (!settings.apiKey) {
|
||
showPanelToast(root, '⚠️ Set your API key first!');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
pdfBtn.textContent = '⏳ Generating...';
|
||
pdfBtn.disabled = true;
|
||
showPanelToast(root, 'Generating ATS CV PDF via Server...', 'info');
|
||
|
||
const response = await new Promise(resolve => {
|
||
chrome.runtime.sendMessage({
|
||
type: 'GEMINI_REQUEST',
|
||
payload: {
|
||
apiKey: settings.apiKey,
|
||
jobDescription: jobData.description,
|
||
jobTitle: jobData.jobTitle || 'Job',
|
||
action: 'generatePdf',
|
||
// Jordan-based postings get the conservative local framing;
|
||
// everything else gets the GCC/international framing.
|
||
template: /jordan|amman|الأردن|عمّان|عمان/i.test(jobData.location || '')
|
||
? 'amman'
|
||
: 'default'
|
||
}
|
||
}, resolve);
|
||
});
|
||
|
||
if (response && response.success && response.data && response.data.pdf) {
|
||
// Convert base64 to Blob and trigger download
|
||
const binaryString = window.atob(response.data.pdf);
|
||
const bytes = new Uint8Array(binaryString.length);
|
||
for (let i = 0; i < binaryString.length; i++) {
|
||
bytes[i] = binaryString.charCodeAt(i);
|
||
}
|
||
const blob = new Blob([bytes], { type: 'application/pdf' });
|
||
const url = window.URL.createObjectURL(blob);
|
||
|
||
const a = document.createElement('a');
|
||
a.style.display = 'none';
|
||
a.href = url;
|
||
a.download = response.data.filename || 'Hamza_Ayed_CV.pdf';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
window.URL.revokeObjectURL(url);
|
||
|
||
// Surface what the tailoring actually achieved, and warn loudly if
|
||
// the server had to fall back because the model tried to fabricate.
|
||
const ats = response.data.ats || {};
|
||
let msg = '✅ PDF downloaded';
|
||
if (ats.pages) msg += ` — ${ats.pages}p`;
|
||
if (ats.score) msg += ` — est. ATS match ${ats.score}%`;
|
||
if (ats.tailored === false) msg += ' (untailored fallback)';
|
||
if (ats.pages > 2) msg += ' ⚠️ over 2 pages';
|
||
if (ats.violations && ats.violations.length) {
|
||
msg += ` ⚠️ blocked fabricated claims: ${ats.violations.join(', ')}`;
|
||
}
|
||
if (ats.missing && ats.missing.length) {
|
||
console.warn('[LJA] Job requirements not claimable from profile:', ats.missing);
|
||
}
|
||
showPanelToast(root, msg, 'success');
|
||
} else {
|
||
throw new Error(response?.error || 'Failed to generate PDF from server');
|
||
}
|
||
} catch (e) {
|
||
showPanelToast(root, '❌ PDF Error: ' + e.message, 'error');
|
||
} finally {
|
||
pdfBtn.textContent = '📥 Get ATS PDF';
|
||
pdfBtn.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── Copy button
|
||
copyBtn.addEventListener('click', () => {
|
||
if (results[currentTab]) {
|
||
navigator.clipboard.writeText(results[currentTab])
|
||
.then(() => showPanelToast(root, '📋 Copied!'))
|
||
.catch(() => showPanelToast(root, 'Copy failed'));
|
||
}
|
||
});
|
||
}
|
||
|
||
// ─── Run Analysis ────────────────────────────────────────────────────────
|
||
|
||
async function runAnalysis(settings, jobData, tab, results, root, loading, loadingText, copyBtn) {
|
||
const loadingMsgs = {
|
||
analysis: 'Analyzing job fit...',
|
||
coverletter: 'Writing cover letter...',
|
||
ats: 'Scoring CV against ATS...',
|
||
cvtips: 'Generating CV tips...',
|
||
qa: 'Preparing Q&A answers...',
|
||
benefits: 'Summarizing benefits...'
|
||
};
|
||
|
||
loading.style.display = 'flex';
|
||
loadingText.textContent = loadingMsgs[tab] || 'Generating...';
|
||
|
||
const pane = root.querySelector(`#lja-pane-${tab}`);
|
||
const analyzeBtn = root.querySelector('#lja-analyze-btn');
|
||
analyzeBtn.disabled = true;
|
||
|
||
try {
|
||
if (tab === 'qa') {
|
||
const freshData = extractApplicationQuestions();
|
||
if (freshData.length > 0) {
|
||
jobData.questions = freshData;
|
||
}
|
||
}
|
||
|
||
const prompt = buildPromptV2(tab, jobData, settings.userProfile, settings.language);
|
||
const response = await chrome.runtime.sendMessage({
|
||
type: 'GEMINI_REQUEST',
|
||
payload: { apiKey: settings.apiKey, prompt, tab }
|
||
});
|
||
|
||
if (!response.success) {
|
||
throw new Error(response.error);
|
||
}
|
||
|
||
const text = response.data.text;
|
||
results[tab] = text;
|
||
|
||
// Render markdown-like output
|
||
const isArabic = /[\u0600-\u06FF]/.test(text);
|
||
const rtlAttr = isArabic ? 'dir="rtl" style="text-align: right; padding-right: 15px;"' : '';
|
||
|
||
// For Q&A, show how many questions were detected
|
||
const qaHeader = (tab === 'qa' && jobData.questions)
|
||
? `<div style="background:rgba(108,99,255,0.2);padding:6px 10px;border-radius:6px;margin-bottom:10px;font-size:12px;color:#b0b0ff;">
|
||
🔍 Detected <strong>${jobData.questions.length}</strong> question(s) from Easy Apply form
|
||
</div>`
|
||
: '';
|
||
|
||
pane.innerHTML = `
|
||
${qaHeader}
|
||
<div class="lja-result" ${rtlAttr}>
|
||
${renderMarkdown(text)}
|
||
</div>
|
||
`;
|
||
|
||
// Attach inline copy button listeners
|
||
if (tab === 'qa') {
|
||
pane.querySelectorAll('.lja-qa-copy-btn').forEach(btn => {
|
||
btn.addEventListener('click', function() {
|
||
navigator.clipboard.writeText(this.getAttribute('data-answer'));
|
||
this.textContent = '✅';
|
||
setTimeout(() => this.textContent = '📋', 1200);
|
||
});
|
||
});
|
||
}
|
||
|
||
copyBtn.style.display = 'block';
|
||
|
||
// Show auto-fill button for Q&A tab
|
||
if (tab === 'qa') {
|
||
const fillBtn = document.createElement('button');
|
||
fillBtn.className = 'lja-fill-btn';
|
||
fillBtn.textContent = '📝 Auto-fill Answers';
|
||
fillBtn.addEventListener('click', () => autoFillAnswers(text, root));
|
||
pane.appendChild(fillBtn);
|
||
}
|
||
|
||
} catch (err) {
|
||
pane.innerHTML = `<div class="lja-error">❌ ${err.message}</div>`;
|
||
} finally {
|
||
loading.style.display = 'none';
|
||
analyzeBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
|
||
// ─── Markdown Renderer ───────────────────────────────────────────────────
|
||
|
||
function renderMarkdown(text) {
|
||
try {
|
||
const startIdx = text.indexOf('{');
|
||
const endIdx = text.lastIndexOf('}');
|
||
if (startIdx !== -1 && endIdx !== -1) {
|
||
const parsed = JSON.parse(text.substring(startIdx, endIdx + 1));
|
||
let html = '<div style="display:flex;flex-direction:column;gap:10px;">';
|
||
const entries = Object.entries(parsed);
|
||
entries.forEach(([q, a], idx) => {
|
||
const safeAnswer = String(a).replace(/"/g, '"').replace(/'/g, ''');
|
||
html += `<div style="background: rgba(255,255,255,0.06); padding: 10px 12px; border-radius: 8px; border-left: 3px solid #6c63ff;">
|
||
<div style="font-weight: 600; font-size: 12px; color: #aaa; margin-bottom: 4px;">❓ ${q}</div>
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<div style="color: #4caf50; font-size: 14px; font-weight: 500; flex: 1;" id="lja-qa-answer-${idx}">💡 ${a}</div>
|
||
<button class="lja-qa-copy-btn" data-answer="${safeAnswer}" style="background: rgba(108,99,255,0.2); border: none; border-radius: 4px; padding: 4px 8px; cursor: pointer; color: #b0b0ff; font-size: 14px; flex-shrink: 0;" title="Copy answer">📋</button>
|
||
</div>
|
||
</div>`;
|
||
});
|
||
html += '</div>';
|
||
return html;
|
||
}
|
||
} catch(e) {
|
||
// Not JSON, continue to normal markdown rendering
|
||
}
|
||
|
||
return text
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
|
||
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
|
||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||
.replace(/(<li>.*<\/li>(\n|$))+/g, m => '<ul>' + m + '</ul>')
|
||
.replace(/\n{2,}/g, '</p><p>')
|
||
.replace(/\n/g, '<br>')
|
||
.replace(/^(?!<[hup])(.+)$/gm, (m) => m.startsWith('<') ? m : '<p>' + m + '</p>');
|
||
}
|
||
|
||
// ─── Auto-fill ───────────────────────────────────────────────────────────
|
||
|
||
function autoFillAnswers(qaText, root) {
|
||
showPanelToast(root, '🔄 Attempting to fill answers...');
|
||
let answers = {};
|
||
try {
|
||
const startIndex = qaText.indexOf('{');
|
||
const endIndex = qaText.lastIndexOf('}');
|
||
if (startIndex !== -1 && endIndex !== -1) {
|
||
answers = JSON.parse(qaText.substring(startIndex, endIndex + 1));
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to parse AI answers JSON:', e);
|
||
}
|
||
|
||
if (Object.keys(answers).length === 0) {
|
||
showPanelToast(root, '⚠️ Could not parse answers. Please copy manually.');
|
||
return;
|
||
}
|
||
|
||
let filled = 0;
|
||
const labels = document.querySelectorAll(
|
||
'.jobs-easy-apply-content label, .jobs-easy-apply-content legend, ' +
|
||
'.artdeco-modal label, .artdeco-modal legend, ' +
|
||
'[role="dialog"] label, [role="dialog"] legend, ' +
|
||
'#artdeco-modal-outlet label, #artdeco-modal-outlet legend'
|
||
);
|
||
|
||
labels.forEach(label => {
|
||
// Use same clean-text logic as the scraper
|
||
const ariaSpan = label.querySelector('span[aria-hidden="true"]');
|
||
const qText = (ariaSpan ? ariaSpan.textContent : label.textContent)
|
||
.replace(/\*/g, '').replace(/required/gi, '').replace(/\n/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
|
||
if (!qText || qText.length < 5) return;
|
||
|
||
const matchedKey = Object.keys(answers).find(k => {
|
||
const kLower = k.toLowerCase().trim();
|
||
return kLower.includes(qText) || qText.includes(kLower);
|
||
});
|
||
|
||
if (matchedKey && answers[matchedKey]) {
|
||
const parent = label.closest('.fb-dash-form-element') || label.parentElement;
|
||
const answerVal = String(answers[matchedKey]);
|
||
|
||
// 1. Fill Text inputs / Textareas
|
||
const input = parent.querySelector('input[type="text"], textarea');
|
||
if (input) {
|
||
input.value = answerVal;
|
||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||
filled++;
|
||
}
|
||
|
||
// 2. Select dropdowns
|
||
const select = parent.querySelector('select');
|
||
if (select) {
|
||
const options = Array.from(select.options);
|
||
const matchedOpt = options.find(o => o.text.toLowerCase() === answerVal.toLowerCase() || o.value.toLowerCase() === answerVal.toLowerCase() || o.text.toLowerCase().includes(answerVal.toLowerCase()));
|
||
if (matchedOpt) {
|
||
select.value = matchedOpt.value;
|
||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||
filled++;
|
||
}
|
||
}
|
||
|
||
// 3. Radio Buttons (Yes/No)
|
||
const radios = parent.querySelectorAll('input[type="radio"]');
|
||
if (radios.length > 0) {
|
||
let radioClicked = false;
|
||
radios.forEach(radio => {
|
||
const radioLabel = radio.parentElement.textContent.trim().toLowerCase();
|
||
const aLower = answerVal.toLowerCase();
|
||
if (radioLabel === aLower || (aLower === 'yes' && radioLabel.includes('yes')) || (aLower === 'no' && radioLabel.includes('no'))) {
|
||
radio.click();
|
||
radioClicked = true;
|
||
}
|
||
});
|
||
if (radioClicked) filled++;
|
||
}
|
||
}
|
||
});
|
||
|
||
showPanelToast(root, filled > 0 ? `✅ Filled ${filled} fields` : '⚠️ No matching fields found to auto-fill.');
|
||
}
|
||
|
||
// ─── Utilities ───────────────────────────────────────────────────────────
|
||
|
||
// Markers that identify a profile saved before the CV was rewritten around
|
||
// the Senior Mobile Architect positioning. A profile containing any of these
|
||
// is describing a person who no longer matches the master CV, so every prompt
|
||
// built from it would contradict the PDF that actually gets sent.
|
||
const STALE_PROFILE_MARKERS = [
|
||
'CTO & Technical Architect',
|
||
'Senior Backend Engineer & Systems Architect',
|
||
'IntaleqMaps',
|
||
'hamzaayed.dev@gmail.com'
|
||
];
|
||
|
||
function isStaleProfile(text) {
|
||
if (!text) return true;
|
||
return STALE_PROFILE_MARKERS.some(marker => text.includes(marker));
|
||
}
|
||
|
||
function getSettings() {
|
||
return new Promise(resolve => {
|
||
chrome.storage.sync.get(['apiKey', 'userProfile', 'language'], (data) => {
|
||
if (isStaleProfile(data.userProfile) && typeof renderProfileText === 'function') {
|
||
console.warn('[LJA] Stored profile is out of date with the master CV — migrating to profile_data.js.');
|
||
data.userProfile = renderProfileText();
|
||
chrome.storage.sync.set({ userProfile: data.userProfile });
|
||
}
|
||
resolve(data);
|
||
});
|
||
});
|
||
}
|
||
|
||
function showPanelToast(root, msg) {
|
||
let toast = root.querySelector('#lja-panel-toast');
|
||
if (!toast) {
|
||
toast = document.createElement('div');
|
||
toast.id = 'lja-panel-toast';
|
||
root.querySelector('#lja-panel').appendChild(toast);
|
||
}
|
||
toast.textContent = msg;
|
||
toast.classList.add('show');
|
||
setTimeout(() => toast.classList.remove('show'), 2500);
|
||
}
|
||
|
||
// ─── List Scanner ────────────────────────────────────────────────────────
|
||
|
||
function injectListScanner() {
|
||
if (document.getElementById('lja-scan-btn')) return;
|
||
|
||
const btn = document.createElement('button');
|
||
btn.id = 'lja-scan-btn';
|
||
btn.innerHTML = '🔍 Scan List';
|
||
btn.style.cssText = `
|
||
position: fixed;
|
||
bottom: 20px;
|
||
left: 20px;
|
||
z-index: 999999;
|
||
background: linear-gradient(135deg, #00d67e, #00a65e);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 50px;
|
||
padding: 12px 24px;
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
cursor: pointer;
|
||
box-shadow: 0 4px 15px rgba(0, 214, 126, 0.4);
|
||
transition: transform 0.2s;
|
||
font-family: Arial, sans-serif;
|
||
`;
|
||
btn.onmouseover = () => btn.style.transform = 'scale(1.05)';
|
||
btn.onmouseout = () => btn.style.transform = 'scale(1)';
|
||
|
||
btn.onclick = async () => {
|
||
btn.innerHTML = '⏳ Scanning...';
|
||
try {
|
||
await scanJobList();
|
||
} catch (e) {
|
||
console.error(e);
|
||
alert('Scan failed: ' + e.message);
|
||
}
|
||
btn.innerHTML = '🔍 Scan List';
|
||
};
|
||
|
||
document.body.appendChild(btn);
|
||
}
|
||
|
||
async function scanJobList() {
|
||
const listItems = document.querySelectorAll('.jobs-search-results__list-item, .job-card-container');
|
||
if (!listItems.length) {
|
||
alert('No job items found on this page. Scroll down to load them.');
|
||
return;
|
||
}
|
||
|
||
const jobsToScan = [];
|
||
listItems.forEach((item, index) => {
|
||
const titleEl = item.querySelector('.job-card-list__title, .artdeco-entity-lockup__title');
|
||
const companyEl = item.querySelector('.job-card-container__primary-description, .artdeco-entity-lockup__subtitle');
|
||
if (titleEl && companyEl) {
|
||
jobsToScan.push({
|
||
index: index,
|
||
title: titleEl.textContent.trim().replace(/\n/g, ''),
|
||
company: companyEl.textContent.trim().replace(/\n/g, ''),
|
||
element: item
|
||
});
|
||
}
|
||
});
|
||
|
||
if (!jobsToScan.length) {
|
||
alert('Could not parse job titles.');
|
||
return;
|
||
}
|
||
|
||
document.querySelectorAll('.lja-badge').forEach(b => b.remove());
|
||
|
||
if (jobsToScan.length === 0) return;
|
||
|
||
// Limit to 25 jobs per scan to prevent AI token truncation
|
||
const jobsToProcess = jobsToScan.slice(0, 25);
|
||
const listDataStr = JSON.stringify(jobsToProcess.map(j => ({ index: j.index, title: j.title, company: j.company })));
|
||
|
||
const settings = await getSettings();
|
||
if (!settings.apiKey) {
|
||
alert('Please set your API key in the extension popup first.');
|
||
return;
|
||
}
|
||
|
||
if (typeof buildPromptV2 !== 'function') {
|
||
alert('buildPromptV2 not found. Extension error.');
|
||
return;
|
||
}
|
||
|
||
const promptStr = buildPromptV2('list_analysis', { skills: [], listData: listDataStr }, settings.userProfile, settings.language);
|
||
|
||
const response = await chrome.runtime.sendMessage({
|
||
type: 'GEMINI_REQUEST',
|
||
payload: { apiKey: settings.apiKey, prompt: promptStr, tab: 'list_analysis' }
|
||
});
|
||
|
||
if (!response.success) {
|
||
console.error('[LJA] Background Error:', response.error);
|
||
throw new Error('API Error: ' + response.error);
|
||
}
|
||
|
||
let resultText = response.data.text;
|
||
resultText = resultText.replace(/```json/gi, '').replace(/```/g, '').trim();
|
||
|
||
let results;
|
||
try {
|
||
const startIdx = resultText.indexOf('[');
|
||
const endIdx = resultText.lastIndexOf(']');
|
||
if (startIdx !== -1 && endIdx !== -1) {
|
||
results = JSON.parse(resultText.substring(startIdx, endIdx + 1));
|
||
} else {
|
||
results = JSON.parse(resultText);
|
||
}
|
||
} catch(e) {
|
||
console.error("AI JSON Parse Error. Raw Response:", resultText);
|
||
throw new Error('JSON Error: ' + resultText.substring(0, 80));
|
||
}
|
||
|
||
results.forEach(res => {
|
||
const jobItem = jobsToScan.find(j => j.index === res.index);
|
||
if (jobItem) {
|
||
const badge = document.createElement('div');
|
||
badge.className = 'lja-badge';
|
||
badge.style.cssText = `
|
||
display: inline-block;
|
||
padding: 4px 8px;
|
||
border-radius: 6px;
|
||
font-size: 11px;
|
||
font-weight: bold;
|
||
margin-top: 6px;
|
||
color: white;
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
`;
|
||
if (res.verdict === 'YES') {
|
||
badge.style.background = 'linear-gradient(135deg, #00d67e, #00a65e)';
|
||
badge.innerHTML = `✅ MATCH: ${res.reason}`;
|
||
} else if (res.verdict === 'NO') {
|
||
badge.style.background = 'linear-gradient(135deg, #ff4d6d, #d90429)';
|
||
badge.innerHTML = `❌ SKIP: ${res.reason}`;
|
||
} else {
|
||
badge.style.background = 'linear-gradient(135deg, #ffb347, #ff9200)';
|
||
badge.innerHTML = `⚠️ MAYBE: ${res.reason}`;
|
||
}
|
||
|
||
const contentContainer = jobItem.element.querySelector('.artdeco-entity-lockup__content');
|
||
if (contentContainer) contentContainer.appendChild(badge);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ─── SPA Navigation Observer ─────────────────────────────────────────────
|
||
|
||
let lastUrl = location.href;
|
||
let lastJobId = new URLSearchParams(location.search).get('currentJobId') || '';
|
||
let debounceTimer;
|
||
|
||
function getJobIdFromUrl() {
|
||
const params = new URLSearchParams(location.search);
|
||
return params.get('currentJobId') || '';
|
||
}
|
||
|
||
function refreshOverlay() {
|
||
const old = document.getElementById('lja-root');
|
||
if (old) old.remove();
|
||
window.__linkedinAnalyzerLoaded = false;
|
||
setTimeout(() => { injectOverlay(); injectListScanner(); }, 1200);
|
||
}
|
||
|
||
// Watch for URL changes (SPA navigation + job switches)
|
||
const observer = new MutationObserver(() => {
|
||
const currentUrl = location.href;
|
||
const currentJobId = getJobIdFromUrl();
|
||
|
||
// Job switched via sidebar click (URL param changed)
|
||
if (currentJobId && currentJobId !== lastJobId) {
|
||
lastJobId = currentJobId;
|
||
lastUrl = currentUrl;
|
||
clearTimeout(debounceTimer);
|
||
debounceTimer = setTimeout(refreshOverlay, 600);
|
||
return;
|
||
}
|
||
|
||
// Full URL change (different page)
|
||
if (currentUrl !== lastUrl) {
|
||
lastUrl = currentUrl;
|
||
lastJobId = currentJobId;
|
||
clearTimeout(debounceTimer);
|
||
debounceTimer = setTimeout(() => {
|
||
if (currentUrl.includes('/jobs/')) {
|
||
refreshOverlay();
|
||
}
|
||
}, 800);
|
||
}
|
||
});
|
||
|
||
observer.observe(document.body, { childList: true, subtree: true });
|
||
|
||
// ─── Init ─────────────────────────────────────────────────────────────────
|
||
|
||
function init() {
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', () => setTimeout(() => { injectOverlay(); injectListScanner(); }, 1000));
|
||
} else {
|
||
setTimeout(() => { injectOverlay(); injectListScanner(); }, 1000);
|
||
}
|
||
}
|
||
|
||
init();
|
||
|
||
})();
|