// ==UserScript==
// @name         LinkedIn Jobs Exporter (humanized, paginated)
// @namespace    https://github.com/ChunkOfSnippet
// @version      1.0.0
// @description  Walks LinkedIn job search results one card at a time with humanized timing, scrapes the detail pane, follows ?start= pagination, and exports CSV/JSON/NDJSON.
// @author       ali raza
// @match        https://www.linkedin.com/jobs/search*
// @match        https://www.linkedin.com/jobs/collections/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_registerMenuCommand
// @run-at       document-idle
// @noframes
// ==/UserScript==

/* global GM_setValue, GM_getValue, GM_deleteValue, GM_registerMenuCommand, unsafeWindow */

(function () {
  'use strict';

  const NS = 'liJobsExporter';
  const PAGE_SIZE = 25; // LinkedIn's ?start= stride

  /* ------------------------------------------------------------------ *
   * storage (GM_* when available, localStorage otherwise)
   * ------------------------------------------------------------------ */

  const hasGM = typeof GM_getValue === 'function';

  const store = {
    get(key, fallback) {
      try {
        const raw = hasGM
          ? GM_getValue(NS + ':' + key, null)
          : localStorage.getItem(NS + ':' + key);
        return raw == null ? fallback : JSON.parse(raw);
      } catch (e) {
        return fallback;
      }
    },
    set(key, value) {
      const raw = JSON.stringify(value);
      if (hasGM) GM_setValue(NS + ':' + key, raw);
      else localStorage.setItem(NS + ':' + key, raw);
    },
    del(key) {
      if (hasGM) GM_deleteValue(NS + ':' + key);
      else localStorage.removeItem(NS + ':' + key);
    },
  };

  /* ------------------------------------------------------------------ *
   * config — every field here is editable from the panel
   * ------------------------------------------------------------------ */

  const DEFAULTS = {
    // humanize
    clickMin: 900,        // ms before moving on to the next card
    clickMax: 2600,
    readMin: 1400,        // ms "reading" the detail pane before scraping
    readMax: 4200,
    breakEvery: 12,       // take a long break every N jobs (0 = never)
    breakMin: 8,          // seconds
    breakMax: 25,
    pageMin: 4,           // seconds between pages
    pageMax: 12,
    humanScroll: true,    // stepped scrolling + micro jitter
    // crawl
    paginate: true,
    pageMode: 'url',      // 'url' -> ?start=N reload, 'button' -> click Next (SPA)
    startOffset: null,    // null => take from current URL
    maxPages: 5,
    // capture
    grabDescription: true,
    skipSeen: true,
  };

  const cfg = Object.assign({}, DEFAULTS, store.get('cfg', {}));
  const saveCfg = () => store.set('cfg', cfg);

  /* ------------------------------------------------------------------ *
   * run state — survives the reload that ?start= pagination causes
   * ------------------------------------------------------------------ */

  const state = Object.assign(
    { running: false, pagesDone: 0, startedAt: 0, lastError: '' },
    store.get('state', {})
  );
  const saveState = () => store.set('state', state);

  let records = store.get('records', {}); // { [jobId]: row }
  const saveRecords = () => store.set('records', records);

  let abort = false; // in-memory cancellation flag for the current loop

  /* ------------------------------------------------------------------ *
   * small utils
   * ------------------------------------------------------------------ */

  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  const rand = (min, max) => min + Math.random() * (max - min);

  // Bell-ish distribution so delays cluster around the middle instead of
  // being uniformly spread — uniform gaps are the obvious bot tell.
  function humanDelay(min, max) {
    const a = Math.min(min, max);
    const b = Math.max(min, max);
    const n = (Math.random() + Math.random() + Math.random()) / 3;
    return Math.round(a + (b - a) * n);
  }

  async function waitUntil(fn, timeout = 15000, step = 120) {
    const deadline = Date.now() + timeout;
    for (;;) {
      if (abort) throw new Error('aborted');
      let ok = false;
      try {
        ok = fn();
      } catch (e) {
        ok = false;
      }
      if (ok) return true;
      if (Date.now() > deadline) return false;
      await sleep(step);
    }
  }

  const txt = (el) => (el ? (el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim() : '');

  function qs(sel, root) {
    return (root || document).querySelector(sel);
  }
  function qsa(sel, root) {
    return Array.from((root || document).querySelectorAll(sel));
  }

  /* ------------------------------------------------------------------ *
   * humanized pointer + scroll
   * ------------------------------------------------------------------ */

  // Tampermonkey's sandbox exposes a Proxy in place of `window`, and the
  // UIEvent constructor refuses to convert it to a real Window. document
  // .defaultView is the genuine article; the try/catch covers the rest.
  const REAL_WINDOW = (typeof unsafeWindow !== 'undefined' && unsafeWindow) || document.defaultView || window;

  function fireMouse(el, type, x, y) {
    const isPointer = type.indexOf('pointer') === 0 && typeof PointerEvent === 'function';
    const Ctor = isPointer ? PointerEvent : MouseEvent;
    const down = type === 'pointerdown' || type === 'mousedown';
    const init = {
      bubbles: true, cancelable: true, composed: true,
      clientX: x, clientY: y, screenX: x, screenY: y,
      button: 0, buttons: down ? 1 : 0, detail: type === 'click' ? 1 : 0,
    };
    if (isPointer) Object.assign(init, { pointerId: 1, pointerType: 'mouse', isPrimary: true, width: 1, height: 1 });

    let ev;
    try {
      ev = new Ctor(type, Object.assign({ view: REAL_WINDOW }, init));
    } catch (e) {
      ev = new Ctor(type, init); // events dispatch fine without `view`
    }
    el.dispatchEvent(ev);
  }

  async function humanClick(el) {
    const r = el.getBoundingClientRect();
    const x = r.left + r.width * rand(0.25, 0.7);
    const y = r.top + r.height * rand(0.3, 0.7);

    fireMouse(el, 'pointerover', x, y);
    fireMouse(el, 'mouseover', x, y);
    // a couple of drifting moves before landing
    for (let i = 0; i < 2 + Math.floor(rand(0, 2)); i++) {
      fireMouse(el, 'mousemove', x + rand(-6, 6), y + rand(-4, 4));
      await sleep(humanDelay(20, 70));
    }
    await sleep(humanDelay(60, 200));

    fireMouse(el, 'pointerdown', x, y);
    fireMouse(el, 'mousedown', x, y);
    el.focus && el.focus({ preventScroll: true });
    await sleep(humanDelay(40, 130));
    fireMouse(el, 'pointerup', x, y);
    fireMouse(el, 'mouseup', x, y);
    fireMouse(el, 'click', x, y);
  }

  function listScroller() {
    // Walk up from a card until we hit the element that actually scrolls.
    let el = qs('li[data-occludable-job-id]');
    while (el && el !== document.body) {
      const s = getComputedStyle(el);
      if (/(auto|scroll)/.test(s.overflowY) && el.scrollHeight > el.clientHeight + 8) return el;
      el = el.parentElement;
    }
    return document.scrollingElement || document.documentElement;
  }

  async function humanScrollTo(el) {
    if (!cfg.humanScroll) {
      el.scrollIntoView({ block: 'center' });
      await sleep(humanDelay(150, 400));
      return;
    }
    const box = listScroller();
    const target =
      el.getBoundingClientRect().top - box.getBoundingClientRect().top + box.scrollTop - box.clientHeight * rand(0.28, 0.45);
    const from = box.scrollTop;
    const dist = target - from;
    const steps = 4 + Math.floor(rand(0, 5));
    for (let i = 1; i <= steps; i++) {
      if (abort) return;
      // ease-out so it decelerates into place
      const t = 1 - Math.pow(1 - i / steps, 2);
      box.scrollTop = from + dist * t + rand(-8, 8);
      await sleep(humanDelay(35, 110));
    }
    box.scrollTop = target;
    await sleep(humanDelay(120, 420));
  }

  // Virtualised list: cards render only near the viewport, so sweep once
  // to force every <li> to hydrate before we start clicking.
  async function primeList() {
    const box = listScroller();
    const start = box.scrollTop;
    for (let y = 0; y <= box.scrollHeight; y += Math.round(box.clientHeight * rand(0.55, 0.8))) {
      if (abort) return;
      box.scrollTop = y;
      await sleep(humanDelay(120, 300));
    }
    box.scrollTop = start;
    await sleep(humanDelay(250, 700));
  }

  /* ------------------------------------------------------------------ *
   * scraping
   * ------------------------------------------------------------------ */

  function cards() {
    return qsa('li[data-occludable-job-id]').filter((li) => li.offsetParent !== null || li.getBoundingClientRect().height > 0);
  }

  function cardJobId(li) {
    return (
      li.getAttribute('data-occludable-job-id') ||
      (qs('[data-job-id]', li) || {}).getAttribute?.('data-job-id') ||
      ''
    );
  }

  function scrapeCard(li) {
    const link = qs('a.job-card-container__link, a.job-card-list__title--link', li);
    return {
      title: link ? link.getAttribute('aria-label') || txt(link) : '',
      company: txt(qs('.artdeco-entity-lockup__subtitle', li)),
      location: txt(qs('.job-card-container__metadata-wrapper li', li)),
      footnotes: qsa('.job-card-container__footer-item', li).map(txt).filter(Boolean).join(' | '),
    };
  }

  function detailPaneMatches(jobId) {
    const a = qs('.job-details-jobs-unified-top-card__job-title a[href*="/jobs/view/"]');
    if (a && a.getAttribute('href').includes('/jobs/view/' + jobId)) return true;
    const cur = new URL(location.href).searchParams.get('currentJobId');
    return cur === String(jobId) && !!qs('#job-details');
  }

  async function expandDescription() {
    const more = qsa('button').find((b) => {
      const t = txt(b).toLowerCase();
      return (t === 'see more' || t === 'show more') && b.closest('.jobs-description, .jobs-description__container');
    });
    if (more) {
      await humanClick(more);
      await sleep(humanDelay(200, 600));
    }
  }

  function scrapeDetail(jobId) {
    const prefs = qsa('.job-details-fit-level-preferences button').map(txt).filter(Boolean);
    const tertiary = txt(qs('.job-details-jobs-unified-top-card__tertiary-description-container'));
    const applyBtn = qs('.jobs-apply-button--top-card button, .jobs-apply-button--top-card');

    // "Lahore, Punjab, Pakistan · 3 days ago · 71 applicants"
    const bits = tertiary.split('·').map((s) => s.trim());
    const posted = bits.find((b) => /\bago\b|\bjust now\b/i.test(b)) || '';
    const applicants = bits.find((b) => /applicant|people clicked/i.test(b)) || '';

    return {
      detailTitle: txt(qs('.job-details-jobs-unified-top-card__job-title h1, .job-details-jobs-unified-top-card__job-title')),
      detailCompany: txt(qs('.job-details-jobs-unified-top-card__company-name')),
      companyUrl: (qs('.job-details-jobs-unified-top-card__company-name a') || {}).href || '',
      detailLocation: bits[0] || '',
      posted,
      applicants,
      tertiary,
      workplace: prefs.find((p) => /on-?site|remote|hybrid/i.test(p)) || '',
      employmentType: prefs.find((p) => /full-?time|part-?time|contract|intern|temporary|volunteer/i.test(p)) || '',
      salary: prefs.find((p) => /\$|₨|PKR|per (hour|year|month)|\/yr|\/hr/i.test(p)) || txt(qs('#SALARY, .jobs-details__salary-main-rail-card')),
      preferences: prefs.join(' | '),
      applyType: /easy apply/i.test(txt(applyBtn)) ? 'Easy Apply' : txt(applyBtn) || '',
      description: cfg.grabDescription ? txt(qs('#job-details')) : '',
      jobUrl: 'https://www.linkedin.com/jobs/view/' + jobId + '/',
    };
  }

  /* ------------------------------------------------------------------ *
   * pagination
   * ------------------------------------------------------------------ */

  const currentStart = () => parseInt(new URL(location.href).searchParams.get('start') || '0', 10) || 0;

  function gotoStart(start) {
    const url = new URL(location.href);
    url.searchParams.set('start', String(start));
    // stale currentJobId just forces LinkedIn to re-open a job from the old page
    url.searchParams.delete('currentJobId');
    log('→ page ' + (start / PAGE_SIZE + 1) + '  (start=' + start + ')');
    saveState();
    saveRecords();
    location.assign(url.toString());
  }

  function nextButton() {
    return qs('.jobs-search-pagination__button--next:not([disabled])');
  }

  /* ------------------------------------------------------------------ *
   * the run loop
   * ------------------------------------------------------------------ */

  async function processPage() {
    await waitUntil(() => cards().length > 0, 20000);
    await primeList();

    const list = cards();
    log('page has ' + list.length + ' cards');
    let done = 0;

    for (let i = 0; i < list.length; i++) {
      if (abort || !state.running) return false;

      const li = list[i];
      const jobId = cardJobId(li);
      if (!jobId) continue;

      if (cfg.skipSeen && records[jobId]) {
        log('skip ' + jobId + ' (already collected)');
        continue;
      }

      // One awkward card must not abort the run and lose the page's work.
      try {
        await humanScrollTo(li);

        const target = qs('a.job-card-container__link, .job-card-container', li) || li;
        const beforePath = location.pathname;
        await humanClick(target);

        // If the anchor escaped the SPA router we'd land on /jobs/view/... —
        // step back rather than losing the results page.
        await sleep(300);
        if (location.pathname !== beforePath && /\/jobs\/view\//.test(location.pathname)) {
          history.back();
          await waitUntil(() => location.pathname === beforePath, 10000);
          await sleep(humanDelay(600, 1200));
        }

        const ok = await waitUntil(() => detailPaneMatches(jobId), 15000);
        if (!ok) {
          log('⚠ detail pane never loaded for ' + jobId + ', skipping');
          continue;
        }

        await sleep(humanDelay(cfg.readMin, cfg.readMax));
        if (cfg.grabDescription) await expandDescription();

        const row = Object.assign(
          {
            jobId,
            page: currentStart() / PAGE_SIZE + 1,
            scrapedAt: new Date().toISOString(),
          },
          scrapeCard(li),
          scrapeDetail(jobId)
        );
        records[jobId] = row;
        saveRecords();
        done++;
        renderCount();
        log('✓ ' + (row.title || row.detailTitle) + ' — ' + (row.company || row.detailCompany));
      } catch (e) {
        if (e && e.message === 'aborted') throw e;
        log('⚠ ' + jobId + ': ' + (e && e.message ? e.message : e));
        continue;
      }

      if (cfg.breakEvery > 0 && done % cfg.breakEvery === 0 && i < list.length - 1) {
        const secs = humanDelay(cfg.breakMin * 1000, cfg.breakMax * 1000);
        log('… break ' + Math.round(secs / 1000) + 's');
        await sleep(secs);
      } else {
        await sleep(humanDelay(cfg.clickMin, cfg.clickMax));
      }
    }

    return true;
  }

  async function run() {
    abort = false;
    state.running = true;
    state.lastError = '';
    if (!state.startedAt) state.startedAt = Date.now();
    saveState();
    renderControls();

    try {
      const finished = await processPage();
      if (!finished) return; // stopped mid-page

      state.pagesDone++;
      saveState();
      renderCount();

      if (!cfg.paginate || state.pagesDone >= cfg.maxPages) {
        log('done — ' + state.pagesDone + ' page(s), ' + Object.keys(records).length + ' jobs');
        stop();
        return;
      }

      await sleep(humanDelay(cfg.pageMin * 1000, cfg.pageMax * 1000));
      if (abort || !state.running) return;

      if (cfg.pageMode === 'button') {
        const btn = nextButton();
        if (!btn) {
          log('no Next button — end of results');
          stop();
          return;
        }
        await humanScrollTo(btn);
        await humanClick(btn);
        await waitUntil(() => cards().length > 0, 20000);
        await sleep(humanDelay(1200, 2600));
        run(); // next page, same session
      } else {
        gotoStart(currentStart() + PAGE_SIZE); // reloads; boot() resumes
      }
    } catch (e) {
      if (e && e.message === 'aborted') return;
      state.lastError = String(e && e.message ? e.message : e);
      log('✖ ' + state.lastError);
      stop();
    }
  }

  function start() {
    if (state.running) return;
    state.pagesDone = 0;
    state.startedAt = Date.now();
    const want = cfg.startOffset;
    if (want != null && want !== '' && Number(want) !== currentStart()) {
      state.running = true;
      saveState();
      gotoStart(Number(want));
      return;
    }
    run();
  }

  function stop() {
    abort = true;
    state.running = false;
    saveState();
    saveRecords();
    renderControls();
    log('stopped');
  }

  /* ------------------------------------------------------------------ *
   * export
   * ------------------------------------------------------------------ */

  const COLUMNS = [
    'jobId', 'page', 'title', 'company', 'location', 'workplace', 'employmentType',
    'salary', 'posted', 'applicants', 'applyType', 'footnotes', 'preferences',
    'companyUrl', 'jobUrl', 'scrapedAt', 'description',
  ];

  function rows() {
    return Object.values(records).sort((a, b) => (a.page - b.page) || String(a.scrapedAt).localeCompare(String(b.scrapedAt)));
  }

  function csvCell(v) {
    const s = v == null ? '' : String(v);
    return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
  }

  // Card and detail pane can each come up empty; prefer whichever has a value.
  function cell(r, col) {
    const alt = 'detail' + col[0].toUpperCase() + col.slice(1);
    return r[col] || r[alt] || '';
  }

  function toCSV() {
    const out = [COLUMNS.join(',')];
    for (const r of rows()) out.push(COLUMNS.map((c) => csvCell(cell(r, c))).join(','));
    return '﻿' + out.join('\r\n'); // BOM so Excel reads it as UTF-8
  }

  function download(name, text, mime) {
    const url = URL.createObjectURL(new Blob([text], { type: mime + ';charset=utf-8' }));
    const a = document.createElement('a');
    a.href = url;
    a.download = name;
    document.body.appendChild(a);
    a.click();
    a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 5000);
  }

  const stamp = () => new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);

  const exporters = {
    csv: () => download('linkedin-jobs-' + stamp() + '.csv', toCSV(), 'text/csv'),
    json: () => download('linkedin-jobs-' + stamp() + '.json', JSON.stringify(rows(), null, 2), 'application/json'),
    ndjson: () => download('linkedin-jobs-' + stamp() + '.ndjson', rows().map((r) => JSON.stringify(r)).join('\n'), 'application/x-ndjson'),
  };

  /* ------------------------------------------------------------------ *
   * UI
   * ------------------------------------------------------------------ */

  // Everything is scoped under #lje-panel and marked !important: LinkedIn ships
  // aggressive global resets for input/select/button that otherwise win and
  // leave the fields rendering white-on-white with invisible checkboxes.
  const CSS = `
  #lje-panel{
    --lje-bg:#ffffff; --lje-fg:#1d2129; --lje-muted:#6b7280; --lje-line:#d7dce3;
    --lje-field:#ffffff; --lje-field-fg:#1d2129; --lje-soft:#f3f5f8;
    --lje-accent:#0a66c2; --lje-accent-fg:#ffffff; --lje-danger:#b42318;
    position:fixed!important;top:70px;right:16px;z-index:2147483000!important;width:322px!important;
    font:12px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif!important;
    color:var(--lje-fg)!important;background:var(--lje-bg)!important;
    border:1px solid var(--lje-line)!important;border-radius:12px!important;
    box-shadow:0 10px 34px rgba(16,24,40,.18)!important;overflow:hidden!important}
  #lje-panel[data-theme="dark"]{
    --lje-bg:#191d21; --lje-fg:#e6edf3; --lje-muted:#8b949e; --lje-line:#333b42;
    --lje-field:#0e1216; --lje-field-fg:#e6edf3; --lje-soft:#23282e;
    --lje-accent:#4493f8; --lje-accent-fg:#08111c; --lje-danger:#ff7b72;
    box-shadow:0 10px 34px rgba(0,0,0,.5)!important}
  #lje-panel *{box-sizing:border-box!important;font-family:inherit!important}

  #lje-head{display:flex!important;align-items:center;gap:6px;padding:9px 8px 9px 12px!important;
    background:var(--lje-accent)!important;color:var(--lje-accent-fg)!important;cursor:move;user-select:none}
  #lje-head b{font-size:12.5px!important;font-weight:600!important;flex:1;letter-spacing:.2px}
  #lje-head button{background:transparent!important;border:0!important;color:inherit!important;cursor:pointer;
    font-size:13px!important;line-height:1!important;padding:3px 6px!important;border-radius:5px!important;
    min-width:0!important;height:auto!important;box-shadow:none!important}
  #lje-head button:hover{background:rgba(127,127,127,.28)!important}

  #lje-body{max-height:72vh!important;overflow-y:auto!important;padding:11px 12px 13px!important;
    background:var(--lje-bg)!important}
  #lje-panel.lje-collapsed #lje-body{display:none!important}

  #lje-panel .lje-sec{font-size:10px!important;font-weight:700!important;letter-spacing:.7px;
    text-transform:uppercase;color:var(--lje-muted)!important;margin:14px 0 7px!important;
    padding-bottom:5px!important;border-bottom:1px solid var(--lje-line)!important}
  #lje-panel .lje-sec:first-child{margin-top:0!important}

  #lje-panel .lje-row{display:flex!important;align-items:center;gap:8px;margin:0 0 7px!important}
  #lje-panel .lje-row>label{flex:1!important;color:var(--lje-fg)!important;margin:0!important;
    font-weight:400!important;font-size:12px!important}
  #lje-panel .lje-dual{display:flex!important;gap:5px}
  #lje-panel input[type=number],#lje-panel select{
    width:74px!important;min-width:0!important;height:26px!important;padding:0 6px!important;margin:0!important;
    border:1px solid var(--lje-line)!important;border-radius:6px!important;
    background:var(--lje-field)!important;color:var(--lje-field-fg)!important;
    font:12px/24px inherit!important;box-shadow:none!important;-webkit-appearance:auto}
  #lje-panel .lje-dual input[type=number]{width:66px!important}
  #lje-panel select{width:112px!important;-webkit-appearance:menulist!important;appearance:menulist!important}
  #lje-panel input[type=number]:focus,#lje-panel select:focus{
    outline:0!important;border-color:var(--lje-accent)!important;box-shadow:0 0 0 2px rgba(10,102,194,.22)!important}

  #lje-panel .lje-chk{display:flex!important;align-items:center;gap:8px;margin:0 0 7px!important;
    color:var(--lje-fg)!important;cursor:pointer;font-weight:400!important;font-size:12px!important}
  #lje-panel input[type=checkbox]{
    -webkit-appearance:none!important;appearance:none!important;
    width:16px!important;height:16px!important;flex:0 0 16px!important;margin:0!important;padding:0!important;
    border:1.5px solid var(--lje-line)!important;border-radius:4px!important;
    background:var(--lje-field)!important;cursor:pointer;position:relative!important;box-shadow:none!important}
  #lje-panel input[type=checkbox]:checked{background:var(--lje-accent)!important;border-color:var(--lje-accent)!important}
  #lje-panel input[type=checkbox]:checked::after{
    content:''!important;position:absolute!important;left:4.5px;top:1.5px;width:4px;height:8px;
    border:solid var(--lje-accent-fg)!important;border-width:0 2px 2px 0!important;transform:rotate(45deg)}

  #lje-panel .lje-btns{display:flex!important;gap:6px;margin-top:11px!important}
  #lje-panel .lje-btns button{flex:1 1 0!important;min-width:0!important;height:30px!important;padding:0 8px!important;
    border-radius:7px!important;border:1px solid var(--lje-accent)!important;
    background:var(--lje-accent)!important;color:var(--lje-accent-fg)!important;
    cursor:pointer;font:600 12px/1 inherit!important;box-shadow:none!important;transition:opacity .12s}
  #lje-panel .lje-btns button.ghost{background:transparent!important;color:var(--lje-accent)!important}
  #lje-panel .lje-btns button.danger{background:transparent!important;
    color:var(--lje-danger)!important;border-color:var(--lje-danger)!important}
  #lje-panel .lje-btns button:hover:not(:disabled){opacity:.85}
  #lje-panel .lje-btns button:disabled{opacity:.4!important;cursor:not-allowed}

  #lje-panel .lje-status{margin-top:8px!important;padding:7px 9px!important;
    background:var(--lje-soft)!important;border-radius:7px!important;color:var(--lje-muted)!important}
  #lje-count{font-weight:700!important;color:var(--lje-accent)!important}
  #lje-log{margin-top:8px!important;height:118px!important;overflow-y:auto!important;padding:7px 9px!important;
    background:#0b0f14!important;color:#c9d1d9!important;border:1px solid var(--lje-line)!important;
    border-radius:7px!important;font:11px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace!important;
    white-space:pre-wrap!important;word-break:break-word}`;

  let logEl, countEl, statusEl, startBtn, stopBtn;

  function log(msg) {
    const line = new Date().toLocaleTimeString() + '  ' + msg;
    if (logEl) {
      logEl.textContent += (logEl.textContent ? '\n' : '') + line;
      logEl.scrollTop = logEl.scrollHeight;
    }
    console.log('[LI-Export]', msg);
  }

  function renderCount() {
    if (countEl) countEl.textContent = Object.keys(records).length;
    if (statusEl) {
      statusEl.textContent = state.running
        ? 'Running · page ' + (currentStart() / PAGE_SIZE + 1) + ' · ' + state.pagesDone + '/' + cfg.maxPages + ' done'
        : 'Idle' + (state.lastError ? ' · ' + state.lastError : '');
    }
  }

  function renderControls() {
    if (startBtn) startBtn.disabled = state.running;
    if (stopBtn) stopBtn.disabled = !state.running;
    renderCount();
  }

  function num(key, min, max, step) {
    const i = document.createElement('input');
    i.type = 'number';
    i.value = cfg[key];
    if (min != null) i.min = min;
    if (max != null) i.max = max;
    if (step != null) i.step = step;
    i.addEventListener('change', () => {
      cfg[key] = i.value === '' ? null : Number(i.value);
      saveCfg();
      renderCount();
    });
    return i;
  }

  function row(labelText, ...controls) {
    const d = document.createElement('div');
    d.className = 'lje-row';
    const l = document.createElement('label');
    l.textContent = labelText;
    d.appendChild(l);
    if (controls.length > 1) {
      const w = document.createElement('div');
      w.className = 'lje-dual';
      controls.forEach((c) => w.appendChild(c));
      d.appendChild(w);
    } else d.appendChild(controls[0]);
    return d;
  }

  function chk(key, labelText) {
    const l = document.createElement('label');
    l.className = 'lje-chk';
    const i = document.createElement('input');
    i.type = 'checkbox';
    i.checked = !!cfg[key];
    i.addEventListener('change', () => {
      cfg[key] = i.checked;
      saveCfg();
    });
    l.appendChild(i);
    l.appendChild(document.createTextNode(labelText));
    return l;
  }

  function sec(text) {
    const d = document.createElement('div');
    d.className = 'lje-sec';
    d.textContent = text;
    return d;
  }

  function buildPanel() {
    if (qs('#lje-panel')) return;

    const style = document.createElement('style');
    style.textContent = CSS;
    document.head.appendChild(style);

    const panel = document.createElement('div');
    panel.id = 'lje-panel';

    const head = document.createElement('div');
    head.id = 'lje-head';
    head.innerHTML = '<b>Jobs Exporter</b>';

    // auto -> light -> dark. "auto" follows the OS, which is what the OS-level
    // media query gives us; the explicit modes are for when LinkedIn's own
    // theme disagrees with it.
    const THEMES = ['auto', 'light', 'dark'];
    const ICON = { auto: '◐', light: '☀', dark: '☾' };
    const applyTheme = () => {
      const t = store.get('theme', 'auto');
      const resolved =
        t !== 'auto' ? t : matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
      panel.setAttribute('data-theme', resolved);
      themeBtn.textContent = ICON[t];
      themeBtn.title = 'Theme: ' + t;
    };
    const themeBtn = document.createElement('button');
    themeBtn.onclick = () => {
      const t = store.get('theme', 'auto');
      store.set('theme', THEMES[(THEMES.indexOf(t) + 1) % THEMES.length]);
      applyTheme();
    };
    head.appendChild(themeBtn);
    matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme);

    const collapse = document.createElement('button');
    collapse.textContent = '–';
    collapse.title = 'Collapse';
    collapse.onclick = () => {
      panel.classList.toggle('lje-collapsed');
      collapse.textContent = panel.classList.contains('lje-collapsed') ? '+' : '–';
      store.set('collapsed', panel.classList.contains('lje-collapsed'));
    };
    head.appendChild(collapse);
    panel.appendChild(head);

    const body = document.createElement('div');
    body.id = 'lje-body';

    body.appendChild(sec('Humanize'));
    body.appendChild(row('Between cards (ms)', num('clickMin', 0, null, 100), num('clickMax', 0, null, 100)));
    body.appendChild(row('Read detail (ms)', num('readMin', 0, null, 100), num('readMax', 0, null, 100)));
    body.appendChild(row('Break every N jobs', num('breakEvery', 0, 100, 1)));
    body.appendChild(row('Break length (s)', num('breakMin', 0, null, 1), num('breakMax', 0, null, 1)));
    body.appendChild(row('Between pages (s)', num('pageMin', 0, null, 1), num('pageMax', 0, null, 1)));
    body.appendChild(chk('humanScroll', 'Eased scrolling + jitter'));

    body.appendChild(sec('Pagination'));
    const mode = document.createElement('select');
    mode.innerHTML = '<option value="url">URL ?start=</option><option value="button">Next button</option>';
    mode.value = cfg.pageMode;
    mode.addEventListener('change', () => {
      cfg.pageMode = mode.value;
      saveCfg();
    });
    body.appendChild(row('Mode', mode));
    body.appendChild(row('Start offset', num('startOffset', 0, null, PAGE_SIZE)));
    body.appendChild(row('Max pages', num('maxPages', 1, 200, 1)));
    body.appendChild(chk('paginate', 'Follow pages automatically'));

    body.appendChild(sec('Capture'));
    body.appendChild(chk('grabDescription', 'Full job description'));
    body.appendChild(chk('skipSeen', 'Skip already-collected jobs'));

    const btns1 = document.createElement('div');
    btns1.className = 'lje-btns';
    startBtn = document.createElement('button');
    startBtn.textContent = 'Start';
    startBtn.onclick = start;
    stopBtn = document.createElement('button');
    stopBtn.textContent = 'Stop';
    stopBtn.className = 'ghost';
    stopBtn.onclick = stop;
    btns1.appendChild(startBtn);
    btns1.appendChild(stopBtn);
    body.appendChild(btns1);

    const btns2 = document.createElement('div');
    btns2.className = 'lje-btns';
    for (const [k, label] of [['csv', 'CSV'], ['json', 'JSON'], ['ndjson', 'NDJSON']]) {
      const b = document.createElement('button');
      b.className = 'ghost';
      b.textContent = label;
      b.onclick = () => {
        if (!Object.keys(records).length) return log('nothing collected yet');
        exporters[k]();
      };
      btns2.appendChild(b);
    }
    body.appendChild(btns2);

    const btns3 = document.createElement('div');
    btns3.className = 'lje-btns';
    const clear = document.createElement('button');
    clear.className = 'danger';
    clear.textContent = 'Clear data';
    clear.onclick = () => {
      if (!confirm('Delete all ' + Object.keys(records).length + ' collected jobs?')) return;
      records = {};
      saveRecords();
      state.pagesDone = 0;
      saveState();
      renderCount();
      log('cleared');
    };
    btns3.appendChild(clear);
    body.appendChild(btns3);

    statusEl = document.createElement('div');
    statusEl.className = 'lje-status';
    body.appendChild(statusEl);

    const cnt = document.createElement('div');
    cnt.className = 'lje-status';
    cnt.style.marginTop = '6px';
    cnt.innerHTML = 'Collected: <span id="lje-count">0</span> jobs';
    body.appendChild(cnt);

    logEl = document.createElement('div');
    logEl.id = 'lje-log';
    body.appendChild(logEl);

    panel.appendChild(body);
    document.body.appendChild(panel);
    countEl = qs('#lje-count');

    applyTheme();
    if (store.get('collapsed', false)) {
      panel.classList.add('lje-collapsed');
      collapse.textContent = '+';
    }

    // drag by header
    let dx = 0, dy = 0, dragging = false;
    head.addEventListener('mousedown', (e) => {
      if (e.target.tagName === 'BUTTON') return;
      dragging = true;
      const r = panel.getBoundingClientRect();
      dx = e.clientX - r.left;
      dy = e.clientY - r.top;
      e.preventDefault();
    });
    document.addEventListener('mousemove', (e) => {
      if (!dragging) return;
      panel.style.left = Math.max(0, e.clientX - dx) + 'px';
      panel.style.top = Math.max(0, e.clientY - dy) + 'px';
      panel.style.right = 'auto';
    });
    document.addEventListener('mouseup', () => (dragging = false));

    renderControls();
  }

  /* ------------------------------------------------------------------ *
   * boot
   * ------------------------------------------------------------------ */

  function boot() {
    buildPanel();
    log('ready · start=' + currentStart() + ' · ' + Object.keys(records).length + ' jobs stored');

    if (state.running) {
      // came back from a ?start= reload — pick up where we left off
      log('resuming after page load…');
      setTimeout(() => {
        if (state.running) run();
      }, humanDelay(1800, 4000));
    }
  }

  if (typeof GM_registerMenuCommand === 'function') {
    GM_registerMenuCommand('Start export', start);
    GM_registerMenuCommand('Stop export', stop);
    GM_registerMenuCommand('Download CSV', () => exporters.csv());
  }

  waitUntil(() => document.body && qs('.scaffold-layout__list, li[data-occludable-job-id]'), 30000).then(boot);
})();
