Auto-expands posts, filters ads, exports to Excel with scroll tracking
Size
17.0 KB
Version
1.0.1
Created
Jan 6, 2026
Updated
15 days ago
1// ==UserScript==
2// @name LinkedIn Feed Automation & Excel Export
3// @description Auto-expands posts, filters ads, exports to Excel with scroll tracking
4// @version 1.0.1
5// @match https://*.linkedin.com/*
6// @icon https://static.licdn.com/aero-v1/sc/h/3loy7tajf3n0cho89wgg0fjre
7// @grant GM.getValue
8// @grant GM.setValue
9// @grant GM.xmlhttpRequest
10// @require https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js
11// ==/UserScript==
12(function() {
13 'use strict';
14
15 // Configuration
16 const CONFIG = {
17 FIRST_RUN_LIMIT: 100,
18 MAX_POSTS: 200,
19 SCROLL_DELAY: 2000,
20 CLICK_DELAY: 500,
21 STORAGE_KEY: 'linkedin_jobs_last_run',
22 JOBS_DATA_KEY: 'linkedin_jobs_data'
23 };
24
25 // State management
26 let isRunning = false;
27 let collectedJobs = [];
28 let processedJobIds = new Set();
29
30 // Utility: Debounce function
31 function debounce(func, wait) {
32 let timeout;
33 return function executedFunction(...args) {
34 const later = () => {
35 clearTimeout(timeout);
36 func(...args);
37 };
38 clearTimeout(timeout);
39 timeout = setTimeout(later, wait);
40 };
41 }
42
43 // Utility: Wait function
44 function wait(ms) {
45 return new Promise(resolve => setTimeout(resolve, ms));
46 }
47
48 // Extract job data from a job card element
49 function extractJobCardData(jobElement) {
50 try {
51 const jobId = jobElement.getAttribute('data-occludable-job-id');
52
53 if (!jobId || processedJobIds.has(jobId)) {
54 return null;
55 }
56
57 const titleElement = jobElement.querySelector('.job-card-list__title--link, a[aria-label*=""]');
58 const title = titleElement ? titleElement.textContent.trim().replace(/\s+/g, ' ') : '';
59 const link = titleElement ? titleElement.href : '';
60
61 const companyElement = jobElement.querySelector('.artdeco-entity-lockup__subtitle, .job-card-container__primary-description');
62 const company = companyElement ? companyElement.textContent.trim() : '';
63
64 const locationElement = jobElement.querySelector('.job-card-container__metadata-item');
65 const location = locationElement ? locationElement.textContent.trim() : '';
66
67 const timeElement = jobElement.querySelector('.job-card-container__listed-time, time');
68 const timePosted = timeElement ? timeElement.textContent.trim() : '';
69
70 console.log(`Extracted job card: ${title} at ${company}`);
71
72 return {
73 jobId,
74 title,
75 company,
76 location,
77 timePosted,
78 link,
79 extractedAt: new Date().toISOString()
80 };
81 } catch (error) {
82 console.error('Error extracting job card data:', error);
83 return null;
84 }
85 }
86
87 // Click on a job to load its details
88 async function clickJobCard(jobElement) {
89 try {
90 const clickableElement = jobElement.querySelector('.job-card-container__link, .job-card-list__title--link');
91 if (clickableElement) {
92 clickableElement.click();
93 await wait(CONFIG.CLICK_DELAY);
94 return true;
95 }
96 } catch (error) {
97 console.error('Error clicking job card:', error);
98 }
99 return false;
100 }
101
102 // Extract detailed job information from the details panel
103 function extractJobDetails() {
104 try {
105 const detailsPanel = document.querySelector('.jobs-details__main-content, .jobs-search__job-details--container');
106 if (!detailsPanel) {
107 return {};
108 }
109
110 // Job type (Contract, Full-time, etc.)
111 const jobTypeElements = detailsPanel.querySelectorAll('.job-details-jobs-unified-top-card__job-insight span, .job-details-jobs-unified-top-card__job-insight');
112 let jobType = '';
113 let workplaceType = '';
114 let isExternal = false;
115
116 jobTypeElements.forEach(el => {
117 const text = el.textContent.trim();
118 if (text.match(/contract|full-time|part-time|temporary|internship|freelance/i)) {
119 jobType = text;
120 }
121 if (text.match(/remote|on-site|hybrid/i)) {
122 workplaceType = text;
123 }
124 });
125
126 // Check if externally managed
127 const externalIndicator = detailsPanel.querySelector('.job-details-jobs-unified-top-card__job-insight--highlight, [class*="external"]');
128 const applyButton = detailsPanel.querySelector('button[aria-label*="Easy Apply"], .jobs-apply-button');
129 isExternal = !applyButton || detailsPanel.textContent.includes('externally') || detailsPanel.textContent.includes('extern');
130
131 // Get full description
132 const descriptionElement = detailsPanel.querySelector('.jobs-description__content, .jobs-description-content__text');
133 const description = descriptionElement ? descriptionElement.textContent.trim() : '';
134
135 // Get seniority level
136 const seniorityElement = detailsPanel.querySelector('.job-details-jobs-unified-top-card__job-insight--highlight');
137 const seniorityLevel = seniorityElement ? seniorityElement.textContent.trim() : '';
138
139 // Get number of applicants
140 const applicantsElement = detailsPanel.querySelector('.job-details-jobs-unified-top-card__applicant-count, [class*="applicant"]');
141 const applicants = applicantsElement ? applicantsElement.textContent.trim() : '';
142
143 console.log(`Extracted details: Type=${jobType}, External=${isExternal}, Workplace=${workplaceType}`);
144
145 return {
146 jobType,
147 workplaceType,
148 isExternal,
149 seniorityLevel,
150 applicants,
151 description: description.substring(0, 500) // Limit description length
152 };
153 } catch (error) {
154 console.error('Error extracting job details:', error);
155 return {};
156 }
157 }
158
159 // Scroll to load more jobs
160 async function scrollToLoadMore() {
161 const jobsList = document.querySelector('.jobs-search-results-list, .scaffold-layout__list');
162 if (jobsList) {
163 jobsList.scrollTop = jobsList.scrollHeight;
164 await wait(CONFIG.SCROLL_DELAY);
165 console.log('Scrolled to load more jobs');
166 return true;
167 }
168 return false;
169 }
170
171 // Main collection function
172 async function collectJobs() {
173 if (isRunning) {
174 console.log('Collection already running');
175 return;
176 }
177
178 isRunning = true;
179 collectedJobs = [];
180 processedJobIds = new Set();
181
182 try {
183 // Get last run info
184 const lastRun = await GM.getValue(CONFIG.STORAGE_KEY, null);
185 const maxJobs = lastRun ? CONFIG.MAX_POSTS : CONFIG.FIRST_RUN_LIMIT;
186
187 console.log(`Starting job collection. Max jobs: ${maxJobs}, Last run: ${lastRun}`);
188 updateStatus(`Collecting jobs... (0/${maxJobs})`);
189
190 let previousCount = 0;
191 let noNewJobsCount = 0;
192
193 while (collectedJobs.length < maxJobs && noNewJobsCount < 3) {
194 // Get all job cards
195 const jobCards = document.querySelectorAll('li[data-occludable-job-id]');
196 console.log(`Found ${jobCards.length} job cards on page`);
197
198 // Process each job card
199 for (let i = 0; i < jobCards.length && collectedJobs.length < maxJobs; i++) {
200 const jobCard = jobCards[i];
201 const basicData = extractJobCardData(jobCard);
202
203 if (!basicData) continue;
204
205 // Check if we've reached the last run timestamp
206 if (lastRun && basicData.extractedAt <= lastRun) {
207 console.log('Reached last run timestamp, stopping collection');
208 noNewJobsCount = 3;
209 break;
210 }
211
212 // Click to load details
213 await clickJobCard(jobCard);
214 await wait(CONFIG.CLICK_DELAY);
215
216 // Extract detailed information
217 const detailedData = extractJobDetails();
218
219 // Combine data
220 const fullJobData = {
221 ...basicData,
222 ...detailedData
223 };
224
225 collectedJobs.push(fullJobData);
226 processedJobIds.add(basicData.jobId);
227
228 updateStatus(`Collecting jobs... (${collectedJobs.length}/${maxJobs})`);
229 console.log(`Collected job ${collectedJobs.length}: ${basicData.title}`);
230 }
231
232 // Check if we got new jobs
233 if (collectedJobs.length === previousCount) {
234 noNewJobsCount++;
235 } else {
236 noNewJobsCount = 0;
237 previousCount = collectedJobs.length;
238 }
239
240 // Scroll to load more
241 if (collectedJobs.length < maxJobs && noNewJobsCount < 3) {
242 await scrollToLoadMore();
243 }
244 }
245
246 // Save collected data
247 await GM.setValue(CONFIG.JOBS_DATA_KEY, JSON.stringify(collectedJobs));
248 await GM.setValue(CONFIG.STORAGE_KEY, new Date().toISOString());
249
250 console.log(`Collection complete. Total jobs: ${collectedJobs.length}`);
251 updateStatus(`Collection complete! ${collectedJobs.length} jobs collected`);
252
253 } catch (error) {
254 console.error('Error during job collection:', error);
255 updateStatus('Error during collection. Check console.');
256 } finally {
257 isRunning = false;
258 }
259 }
260
261 // Export to Excel
262 async function exportToExcel() {
263 try {
264 const savedData = await GM.getValue(CONFIG.JOBS_DATA_KEY, '[]');
265 const jobs = JSON.parse(savedData);
266
267 if (jobs.length === 0) {
268 alert('No jobs to export. Please collect jobs first.');
269 return;
270 }
271
272 console.log(`Exporting ${jobs.length} jobs to Excel`);
273
274 // Prepare data for Excel
275 const excelData = jobs.map(job => ({
276 'Job ID': job.jobId,
277 'Title': job.title,
278 'Company': job.company,
279 'Location': job.location,
280 'Job Type': job.jobType || '',
281 'Workplace Type': job.workplaceType || '',
282 'External': job.isExternal ? 'Yes' : 'No',
283 'Seniority Level': job.seniorityLevel || '',
284 'Applicants': job.applicants || '',
285 'Time Posted': job.timePosted,
286 'Description': job.description || '',
287 'Link': job.link,
288 'Extracted At': job.extractedAt
289 }));
290
291 // Create workbook
292 const ws = XLSX.utils.json_to_sheet(excelData);
293 const wb = XLSX.utils.book_new();
294 XLSX.utils.book_append_sheet(wb, ws, 'LinkedIn Jobs');
295
296 // Auto-size columns
297 const maxWidth = 50;
298 const colWidths = Object.keys(excelData[0]).map(key => {
299 const maxLen = Math.max(
300 key.length,
301 ...excelData.map(row => String(row[key] || '').length)
302 );
303 return { wch: Math.min(maxLen, maxWidth) };
304 });
305 ws['!cols'] = colWidths;
306
307 // Generate filename with timestamp
308 const timestamp = new Date().toISOString().split('T')[0];
309 const filename = `LinkedIn_Jobs_${timestamp}.xlsx`;
310
311 // Write file
312 XLSX.writeFile(wb, filename);
313
314 console.log(`Excel file exported: ${filename}`);
315 updateStatus(`Exported ${jobs.length} jobs to ${filename}`);
316
317 } catch (error) {
318 console.error('Error exporting to Excel:', error);
319 alert('Error exporting to Excel. Check console for details.');
320 }
321 }
322
323 // Export to JSON
324 async function exportToJSON() {
325 try {
326 const savedData = await GM.getValue(CONFIG.JOBS_DATA_KEY, '[]');
327 const jobs = JSON.parse(savedData);
328
329 if (jobs.length === 0) {
330 alert('No jobs to export. Please collect jobs first.');
331 return;
332 }
333
334 const timestamp = new Date().toISOString().split('T')[0];
335 const filename = `LinkedIn_Jobs_${timestamp}.json`;
336
337 const blob = new Blob([JSON.stringify(jobs, null, 2)], { type: 'application/json' });
338 const url = URL.createObjectURL(blob);
339
340 const a = document.createElement('a');
341 a.href = url;
342 a.download = filename;
343 a.click();
344
345 URL.revokeObjectURL(url);
346
347 console.log(`JSON file exported: ${filename}`);
348 updateStatus(`Exported ${jobs.length} jobs to ${filename}`);
349
350 } catch (error) {
351 console.error('Error exporting to JSON:', error);
352 alert('Error exporting to JSON. Check console for details.');
353 }
354 }
355
356 // Update status display
357 function updateStatus(message) {
358 const statusElement = document.getElementById('linkedin-jobs-status');
359 if (statusElement) {
360 statusElement.textContent = message;
361 }
362 }
363
364 // Create UI controls
365 function createUI() {
366 // Check if UI already exists
367 if (document.getElementById('linkedin-jobs-control-panel')) {
368 return;
369 }
370
371 const controlPanel = document.createElement('div');
372 controlPanel.id = 'linkedin-jobs-control-panel';
373 controlPanel.innerHTML = `
374 <div style="position: fixed; top: 80px; right: 20px; z-index: 9999; background: white; border: 2px solid #0a66c2; border-radius: 8px; padding: 15px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); min-width: 280px; font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
375 <div style="font-weight: 600; font-size: 16px; color: #000; margin-bottom: 12px; display: flex; align-items: center; justify-content: space-between;">
376 <span>LinkedIn Jobs Extractor</span>
377 <button id="linkedin-jobs-close" style="background: none; border: none; font-size: 20px; cursor: pointer; color: #666; padding: 0; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center;">×</button>
378 </div>
379 <div id="linkedin-jobs-status" style="font-size: 13px; color: #666; margin-bottom: 12px; min-height: 20px;">Ready to collect jobs</div>
380 <button id="linkedin-jobs-collect" style="width: 100%; padding: 10px; background: #0a66c2; color: white; border: none; border-radius: 4px; font-weight: 600; cursor: pointer; margin-bottom: 8px; font-size: 14px;">
381 Start Collection
382 </button>
383 <button id="linkedin-jobs-export-excel" style="width: 100%; padding: 10px; background: #057642; color: white; border: none; border-radius: 4px; font-weight: 600; cursor: pointer; margin-bottom: 8px; font-size: 14px;">
384 Export to Excel
385 </button>
386 <button id="linkedin-jobs-export-json" style="width: 100%; padding: 10px; background: #5e5e5e; color: white; border: none; border-radius: 4px; font-weight: 600; cursor: pointer; font-size: 14px;">
387 Export to JSON
388 </button>
389 </div>
390 `;
391
392 document.body.appendChild(controlPanel);
393
394 // Add event listeners
395 document.getElementById('linkedin-jobs-collect').addEventListener('click', collectJobs);
396 document.getElementById('linkedin-jobs-export-excel').addEventListener('click', exportToExcel);
397 document.getElementById('linkedin-jobs-export-json').addEventListener('click', exportToJSON);
398 document.getElementById('linkedin-jobs-close').addEventListener('click', () => {
399 controlPanel.remove();
400 });
401
402 console.log('LinkedIn Jobs Extractor UI created');
403 }
404
405 // Initialize
406 async function init() {
407 // Only run on LinkedIn jobs pages
408 if (!window.location.href.includes('linkedin.com/jobs')) {
409 console.log('Not on LinkedIn jobs page, extension inactive');
410 return;
411 }
412
413 console.log('LinkedIn Jobs Extractor initialized');
414
415 // Wait for page to load
416 if (document.readyState === 'loading') {
417 document.addEventListener('DOMContentLoaded', createUI);
418 } else {
419 // Wait a bit for dynamic content to load
420 setTimeout(createUI, 2000);
421 }
422 }
423
424 // Start the extension
425 init();
426
427})();