Steam Guide Saver

Saves any Steam Community guide as a self-contained offline HTML file. Strips Steam's chrome and replaces it with a clean Wikipedia-style reading layout, inlines all images as WebP (GIFs preserved with animation), includes a floating table of contents, and a tap-to-zoom lightbox. YouTube embeds are replaced with titled links.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Steam Guide Saver
// @namespace    https://greasyfork.org/users/YOUR_USERNAME
// @version      1.0.0
// @description  Saves any Steam Community guide as a self-contained offline HTML file. Strips Steam's chrome and replaces it with a clean Wikipedia-style reading layout, inlines all images as WebP (GIFs preserved with animation), includes a floating table of contents, and a tap-to-zoom lightbox. YouTube embeds are replaced with titled links.
// @author       Camarron and Claude
// @license      MIT
// @match        https://steamcommunity.com/sharedfiles/filedetails/*
// @match        https://steamcommunity.com/workshop/filedetails/*
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @connect      steamuserimages-a.akamaihd.net
// @connect      cdn.akamai.steamstatic.com
// @connect      steamcdn-a.akamaihd.net
// @connect      shared.akamai.steamstatic.com
// @connect      community.akamai.steamstatic.com
// @connect      images.steamusercontent.com
// @connect      youtube.com
// @run-at       document-idle
// ==/UserScript==

// MIT License
//
// Copyright (c) 2026 YOUR_NAME
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

(function () {
  'use strict';

  // ─── Inject the save bar UI into the live page ─────────────────────────────

  GM_addStyle(`
    #sgs-bar {
      display: flex;
      gap: 10px;
      align-items: center;
      padding: 10px 16px;
      background: #1b2838;
      border: 1px solid #4c6b22;
      border-radius: 4px;
      margin-bottom: 14px;
      font-family: Arial, sans-serif;
      flex-wrap: wrap;
    }
    #sgs-bar span.sgs-label {
      color: #c6d4df;
      font-size: 13px;
      font-weight: bold;
    }
    #sgs-save-btn {
      padding: 7px 18px;
      background: #4c7a2e;
      color: #fff;
      border: none;
      border-radius: 3px;
      cursor: pointer;
      font-size: 13px;
      font-weight: bold;
      transition: filter 0.15s;
    }
    #sgs-save-btn:hover  { filter: brightness(1.2); }
    #sgs-save-btn:active { filter: brightness(0.85); }
    #sgs-save-btn:disabled { filter: brightness(0.6); cursor: not-allowed; }
    #sgs-progress { flex: 1; min-width: 180px; }
    #sgs-progress-bar-wrap {
      background: #2a3f4f;
      border-radius: 3px;
      height: 8px;
      width: 100%;
      overflow: hidden;
      display: none;
    }
    #sgs-progress-bar-fill {
      height: 100%;
      width: 0%;
      background: #4c7a2e;
      border-radius: 3px;
      transition: width 0.2s ease;
    }
    #sgs-status {
      color: #a0b8c8;
      font-size: 12px;
      font-style: italic;
      margin-top: 3px;
    }
  `);

  function injectBar() {
    const anchor =
      document.querySelector('.guideHeaderContent') ||
      document.querySelector('#guide_area_main') ||
      document.querySelector('.breadcrumbs');

    if (!anchor) { setTimeout(injectBar, 800); return; }

    const bar = document.createElement('div');
    bar.id = 'sgs-bar';
    bar.innerHTML = `
      <span class="sgs-label">&#128190; Steam Guide Saver</span>
      <button id="sgs-save-btn">Save Offline HTML</button>
      <div id="sgs-progress">
        <div id="sgs-progress-bar-wrap"><div id="sgs-progress-bar-fill"></div></div>
        <div id="sgs-status"></div>
      </div>
    `;
    anchor.parentNode.insertBefore(bar, anchor);
    document.getElementById('sgs-save-btn').addEventListener('click', runSave);
  }

  function setStatus(msg) {
    const el = document.getElementById('sgs-status');
    if (el) el.textContent = msg;
  }

  function setProgress(pct) {
    const wrap = document.getElementById('sgs-progress-bar-wrap');
    const fill = document.getElementById('sgs-progress-bar-fill');
    if (!wrap || !fill) return;
    wrap.style.display = 'block';
    fill.style.width = Math.min(100, Math.round(pct)) + '%';
  }

  // ─── GM_xmlhttpRequest as a Promise ────────────────────────────────────────

  function gmFetch(url, responseType) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        responseType: responseType || 'arraybuffer',
        onload:   (r) => resolve(r),
        onerror:  (e) => reject(e),
        ontimeout: () => reject(new Error('timeout: ' + url)),
        timeout: 30000,
      });
    });
  }

  // ─── Detect animated GIF from raw bytes ────────────────────────────────────
  // Animated GIFs contain more than one Graphic Control Extension (0x21 0xF9)

  function isAnimatedGif(arrayBuffer) {
    const bytes = new Uint8Array(arrayBuffer);
    let count = 0;
    for (let i = 0; i < bytes.length - 1; i++) {
      if (bytes[i] === 0x21 && bytes[i + 1] === 0xF9) {
        if (++count > 1) return true;
      }
    }
    return false;
  }

  // ─── ArrayBuffer → base64 string ───────────────────────────────────────────

  function arrayBufferToBase64(buffer) {
    const bytes = new Uint8Array(buffer);
    let binary = '';
    // Process in chunks to avoid stack overflow on large images
    const chunkSize = 8192;
    for (let i = 0; i < bytes.byteLength; i += chunkSize) {
      binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
    }
    return btoa(binary);
  }

  // ─── Fetch one image and return a data URI ──────────────────────────────────
  // GIFs are passed through raw (preserves animation).
  // JPG/PNG are re-encoded as WebP at 0.85 quality via canvas.

  async function fetchImageAsDataURI(url) {
    const isGif = /\.gif($|\?)/i.test(url);

    const res = await gmFetch(url, 'arraybuffer');
    if (!res || res.status > 299) return null;

    const buffer = res.response;

    if (isGif) {
      const animated = isAnimatedGif(buffer);
      const b64 = arrayBufferToBase64(buffer);
      return { dataURI: `data:image/gif;base64,${b64}`, isGif: true, isAnimated: animated };
    }

    // JPG / PNG — convert to WebP via canvas (no CORS issue since we own the bytes)
    const b64 = arrayBufferToBase64(buffer);
    const mimeGuess = /\.png($|\?)/i.test(url) ? 'image/png' : 'image/jpeg';
    const srcDataURI = `data:${mimeGuess};base64,${b64}`;

    return new Promise((resolve) => {
      const img = new Image();
      img.onload = () => {
        const canvas = document.createElement('canvas');
        canvas.width  = img.naturalWidth;
        canvas.height = img.naturalHeight;
        const ctx = canvas.getContext('2d');
        // White background ensures no transparency artefacts on JPEG-origin images
        ctx.fillStyle = '#ffffff';
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        ctx.drawImage(img, 0, 0);
        const webp = canvas.toDataURL('image/webp', 0.85);
        resolve({ dataURI: webp, isGif: false, isAnimated: false });
      };
      img.onerror = () => {
        // Fall back to the original format if canvas conversion fails
        resolve({ dataURI: srcDataURI, isGif: false, isAnimated: false });
      };
      img.src = srcDataURI;
    });
  }

  // ─── Replace YouTube iframes with linked titles ─────────────────────────────
  // Fetches video title via YouTube's oEmbed API (no key required).
  // Falls back to "YouTube Video" if the fetch fails.
  // Must be called on the clone BEFORE stripUnwanted removes all iframes.

  async function replaceYouTubeIframes(clone) {
    const ytIframes = Array.from(clone.querySelectorAll('iframe')).filter((f) =>
      /youtube\.com\/embed\//i.test(f.getAttribute('src') || '')
    );

    for (const frame of ytIframes) {
      const src = frame.getAttribute('src') || '';
      let videoId = '';
      try {
        videoId = new URL(src).pathname.replace('/embed/', '').split('/')[0].split('?')[0];
      } catch { frame.remove(); continue; }

      if (!videoId) { frame.remove(); continue; }

      const watchURL = `https://www.youtube.com/watch?v=${videoId}`;
      let title = 'YouTube Video';

      try {
        const oembedURL = `https://www.youtube.com/oembed?url=${encodeURIComponent(watchURL)}&format=json`;
        const res = await gmFetch(oembedURL, 'text');
        if (res && res.status === 200) {
          const json = JSON.parse(res.responseText);
          if (json.title) title = json.title;
        }
      } catch { /* fall back to generic title */ }

      const link = document.createElement('a');
      link.href = watchURL;
      link.target = '_blank';
      link.rel = 'noopener noreferrer';
      link.textContent = title;
      frame.parentNode.replaceChild(link, frame);
    }
  }

  // ─── Strip Steam chrome from the cloned DOM ─────────────────────────────────

  function stripUnwanted(clone) {
    [
      '#global_header', '#footer', '#footer_spacer',
      '.responsive_header', '.supernav_container',
      '#rightContents',
      '.rightSideContent', '.guideRightSidebar', '.guide_sidebar',
      '.workshopItemStats', '.workshopItemControls',
      '.breadcrumbs', '.workshop_item_awards',
      '[id="-1"]',
      '.commentsSection', '.commentthread_area',
      '.highlight_strip', '.apphub_background',
      '.home_viewmore_tabs',
      '#sgs-bar', 'script', 'iframe', 'noscript',
    ].forEach((sel) => {
      clone.querySelectorAll(sel).forEach((el) => el.remove());
    });
  }

  // ─── Extract guide content ──────────────────────────────────────────────────

  function extractGuideContent() {
    // Real container: div.guide.subSections holds all div.subSection.detailBox sections
    const contentEl =
      document.querySelector('.guide.subSections') ||
      document.querySelector('.guide')             ||
      document.querySelector('#profileBlock');

    // Steam page title format: "Steam Community :: Guide :: GUIDE NAME"
    let titleText = document.title
      .replace(/^Steam Community\s*::\s*Guide\s*::\s*/i, '')
      .trim();
    if (!titleText) titleText = document.title;

    // Author is in the first .friendBlockContent (inside the "Created by" sidebar panel)
    // Its text content is "Author Name\nOffline" — take just the first line
    const authorEl = document.querySelector('.friendBlockContent');
    const authorText = authorEl
      ? authorEl.textContent.trim().split('\n')[0].trim()
      : '';

    return { titleText, authorText, contentEl };
  }

  // ─── Build TOC from section titles in the cloned content ───────────────────

  function buildTOC(contentClone) {
    // Steam guide sections use div.subSectionTitle for their headings
    const headings = Array.from(contentClone.querySelectorAll('.subSectionTitle'))
      .filter((h) => h.textContent.trim().length > 0);

    if (headings.length < 2) return { inlineTOC: '', floatTOC: '' };

    let items = '';
    headings.forEach((h, i) => {
      const id   = `sgs-s${i}`;
      const text = h.textContent.trim();
      h.setAttribute('id', id);
      items += `<li><a href="#${id}">${escapeHTML(text)}</a></li>`;
    });

    const inlineTOC = `<nav id="sgs-toc" aria-label="Table of contents">
  <div class="sgs-toc-title">Contents</div>
  <ol>${items}</ol>
</nav>`;

    const floatTOC = `<ol>${items}</ol>`;

    return { inlineTOC, floatTOC };
  }

  // ─── Utility ───────────────────────────────────────────────────────────────

  function escapeHTML(str) {
    return String(str)
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;');
  }

  // ─── CSS baked into the output file ────────────────────────────────────────

  function buildOutputCSS() {
    return `
/* ── Reset ── */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}

/* ── Base ── */
html{font-size:17px;scroll-behavior:smooth}
body{
  background:#f8f9fa;
  color:#202122;
  font-family:Georgia,'Times New Roman',serif;
  line-height:1.7;
}

/* ── Page wrapper ── */
#sgs-page{
  max-width:880px;
  margin:0 auto;
  padding:2rem 1.5rem 5rem;
}

/* ── Guide header ── */
#sgs-header{
  border-bottom:1px solid #a2a9b1;
  margin-bottom:1.5rem;
  padding-bottom:0.75rem;
}
#sgs-header h1{
  font-size:1.95rem;
  font-weight:normal;
  color:#000;
  line-height:1.25;
  margin-bottom:0.3rem;
}
.sgs-meta{font-size:0.82rem;color:#54595d}

/* ── Table of contents (inline) ── */
#sgs-toc{
  background:#f8f9fa;
  border:1px solid #a2a9b1;
  display:inline-block;
  padding:0.7rem 1.2rem 0.8rem 1rem;
  margin-bottom:1.4rem;
  min-width:180px;
  font-size:0.9rem;
  clear:both;
}
.sgs-toc-title{
  font-weight:bold;
  font-size:0.92rem;
  margin-bottom:0.35rem;
  font-family:Georgia,serif;
}
#sgs-toc ol{margin-left:1.3rem}
#sgs-toc li{margin:0.18rem 0}
#sgs-toc a{color:#0645ad;text-decoration:none}
#sgs-toc a:hover{text-decoration:underline}

/* ── Guide content ── */
#sgs-content{color:#202122}

/* Section title — top level heading, largest */
#sgs-content .subSectionTitle{
  font-family:Georgia,serif;
  font-weight:normal;
  font-size:1.65rem;
  border-bottom:1px solid #a2a9b1;
  margin:2rem 0 0.6rem;
  padding-bottom:0.2rem;
  color:#000;
}

/* bb_h1 — first sub-level within a section */
#sgs-content .bb_h1,#sgs-content h1{
  font-family:Georgia,serif;
  font-weight:normal;
  font-size:1.3rem;
  border-bottom:1px solid #a2a9b1;
  margin:1.5rem 0 0.5rem;
  padding-bottom:0.2rem;
  color:#000;
}

/* bb_h2 — second sub-level */
#sgs-content .bb_h2,#sgs-content h2{
  font-family:Georgia,serif;
  font-weight:normal;
  font-size:1.1rem;
  border-bottom:none;
  margin:1.2rem 0 0.3rem;
  color:#000;
}

/* bb_h3 — third sub-level, no underline, slightly bold */
#sgs-content .bb_h3,#sgs-content h3,
#sgs-content .guideSection,#sgs-content .sectionTitle{
  font-family:Georgia,serif;
  font-weight:bold;
  font-size:1rem;
  border-bottom:none;
  margin:1rem 0 0.25rem;
  color:#000;
}

#sgs-content p,#sgs-content .bb_p{margin:0.55rem 0}
#sgs-content a{color:#0645ad}
#sgs-content a:visited{color:#0b0080}

#sgs-content ul,#sgs-content ol{margin:0.5rem 0 0.5rem 1.8rem}
#sgs-content li{margin:0.18rem 0}

#sgs-content table{border-collapse:collapse;margin:0.9rem 0;width:100%;font-size:0.91rem}
#sgs-content th,#sgs-content td{border:1px solid #a2a9b1;padding:0.38rem 0.6rem;text-align:left}
#sgs-content th{background:#eaecf0;font-weight:bold}
#sgs-content tr:nth-child(even) td{background:#f4f4f4}

#sgs-content blockquote,#sgs-content .bb_blockquote{
  border-left:4px solid #a2a9b1;
  margin:0.7rem 0 0.7rem 1rem;
  padding:0.3rem 0.8rem;
  color:#54595d;
}
#sgs-content code,#sgs-content .bb_code{
  font-family:'Courier New',monospace;
  background:#eaecf0;
  padding:0.1em 0.3em;
  border-radius:2px;
  font-size:0.87em;
}
#sgs-content pre{
  background:#eaecf0;
  padding:0.75rem 1rem;
  border-radius:3px;
  overflow-x:auto;
  font-size:0.87em;
  margin:0.7rem 0;
}
#sgs-content hr{border:none;border-top:1px solid #a2a9b1;margin:1.1rem 0}

/* ── Images ── */
#sgs-content img{
  max-width:100%;
  height:auto;
  display:block;
  cursor:zoom-in;
  margin:0.5rem 0;
  border-radius:2px;
}
#sgs-content img.sgs-gif-anim{cursor:default}

/* ── Lightbox ── */
#sgs-lightbox{
  display:none;
  position:fixed;
  inset:0;
  background:rgba(0,0,0,0.88);
  z-index:9999;
  align-items:center;
  justify-content:center;
  cursor:zoom-out;
}
#sgs-lightbox.active{display:flex}
#sgs-lightbox-img{
  max-width:95vw;
  max-height:92vh;
  object-fit:contain;
  border-radius:2px;
  box-shadow:0 4px 32px rgba(0,0,0,0.6);
  cursor:default;
}
#sgs-lb-close{
  position:fixed;
  top:1rem;right:1.25rem;
  color:#fff;font-size:2rem;
  cursor:pointer;line-height:1;
  opacity:0.8;background:none;border:none;
  font-family:Arial,sans-serif;
}
#sgs-lb-close:hover{opacity:1}

/* ── Floating TOC button ── */
#sgs-float-btn{
  position:fixed;
  bottom:1.5rem;right:1.5rem;
  background:#fff;
  border:1px solid #a2a9b1;
  border-radius:50%;
  width:2.8rem;height:2.8rem;
  display:flex;align-items:center;justify-content:center;
  cursor:pointer;
  box-shadow:0 2px 8px rgba(0,0,0,0.18);
  font-size:1.1rem;
  z-index:1000;
  opacity:0;pointer-events:none;
  transition:opacity 0.2s ease;
}
#sgs-float-btn.visible{opacity:1;pointer-events:auto}
#sgs-float-btn:hover{background:#eaecf0}

/* ── Floating TOC panel ── */
#sgs-float-panel{
  position:fixed;
  bottom:5rem;right:1.5rem;
  background:#fff;
  border:1px solid #a2a9b1;
  border-radius:4px;
  padding:0.7rem 1rem;
  max-height:60vh;overflow-y:auto;
  width:260px;
  box-shadow:0 4px 16px rgba(0,0,0,0.15);
  z-index:1000;
  display:none;
  font-size:0.87rem;
}
#sgs-float-panel.open{display:block}
#sgs-float-panel ol{margin-left:1.1rem}
#sgs-float-panel li{margin:0.22rem 0}
#sgs-float-panel a{color:#0645ad;text-decoration:none}
#sgs-float-panel a:hover{text-decoration:underline}

/* ── Mobile / touch ── */
@media (pointer:coarse),(max-width:640px){
  html{font-size:16px}
  #sgs-page{padding:1rem 1rem 4rem}
  #sgs-header h1{font-size:1.45rem}
  #sgs-toc{width:100%;max-width:100%}
  #sgs-float-panel{
    right:0.75rem;
    bottom:4.5rem;
    width:calc(100vw - 1.5rem);
    max-width:340px;
  }
  #sgs-float-btn{
    bottom:1rem;right:0.75rem;
    width:3.2rem;height:3.2rem;
    font-size:1.3rem;
  }
  #sgs-float-panel a,#sgs-toc a{display:block;padding:0.12rem 0}
}

/* ── Desktop / pointer ── */
@media (pointer:fine) and (min-width:641px){
  #sgs-toc{float:right;margin:0 0 1rem 1.5rem;max-width:260px}
}
    `;
  }

  // ─── JS baked into the output file ─────────────────────────────────────────

  function buildOutputJS() {
    return `
(function(){
  // Lightbox
  var lb    = document.getElementById('sgs-lightbox');
  var lbImg = document.getElementById('sgs-lightbox-img');
  var lbClose = document.getElementById('sgs-lb-close');

  document.querySelectorAll('#sgs-content img:not(.sgs-gif-anim)').forEach(function(img){
    img.addEventListener('click', function(){
      lbImg.src = img.dataset.full || img.src;
      lb.classList.add('active');
      document.body.style.overflow='hidden';
    });
  });

  function closeLB(){
    lb.classList.remove('active');
    lbImg.src='';
    document.body.style.overflow='';
  }
  if(lbClose) lbClose.addEventListener('click', closeLB);
  lb.addEventListener('click', function(e){ if(e.target===lb) closeLB(); });
  document.addEventListener('keydown', function(e){ if(e.key==='Escape') closeLB(); });

  // Floating TOC
  var toc        = document.getElementById('sgs-toc');
  var floatBtn   = document.getElementById('sgs-float-btn');
  var floatPanel = document.getElementById('sgs-float-panel');

  if(toc && floatBtn){
    var obs = new IntersectionObserver(function(entries){
      entries.forEach(function(en){
        if(en.isIntersecting){
          floatBtn.classList.remove('visible');
          floatPanel.classList.remove('open');
        } else {
          floatBtn.classList.add('visible');
        }
      });
    },{threshold:0});
    obs.observe(toc);

    floatBtn.addEventListener('click', function(e){
      e.stopPropagation();
      floatPanel.classList.toggle('open');
    });
    document.addEventListener('click', function(e){
      if(!floatPanel.contains(e.target) && e.target!==floatBtn){
        floatPanel.classList.remove('open');
      }
    });
    floatPanel.querySelectorAll('a').forEach(function(a){
      a.addEventListener('click', function(){
        floatPanel.classList.remove('open');
      });
    });
  }
})();
    `;
  }

  // ─── Main save routine ──────────────────────────────────────────────────────

  async function runSave() {
    const btn = document.getElementById('sgs-save-btn');
    btn.disabled = true;
    setProgress(0);

    try {
      setStatus('Reading guide…');

      const { titleText, authorText, contentEl } = extractGuideContent();
      if (!contentEl) throw new Error('Could not locate guide content on this page.');

      const contentClone = contentEl.cloneNode(true);
      await replaceYouTubeIframes(contentClone);  // must run before stripUnwanted removes all iframes
      stripUnwanted(contentClone);

      // ── Process images ────────────────────────────────────────────────────
      const images = Array.from(contentClone.querySelectorAll('img[src]'));
      const total  = images.length;
      let done = 0;

      setStatus(`Processing ${total} image${total !== 1 ? 's' : ''}…`);
      setProgress(5);

      for (const img of images) {
        const rawSrc = img.getAttribute('src');
        if (!rawSrc || rawSrc.startsWith('data:')) { done++; continue; }

        let displayURL, fullResURL;
        try {
          displayURL = new URL(rawSrc, location.href).href;
          // Steam wraps guide images in <a class="modalContentLink" href="FULL_RES_URL">
          // Use that href directly as the full-res URL if available
          const parentAnchor = img.closest('a.modalContentLink');
          if (parentAnchor && parentAnchor.href) {
            fullResURL = new URL(parentAnchor.href, location.href).href;
          } else {
            // Fallback: strip Steam's image scaling query params
            const u = new URL(displayURL);
            ['imw','imh','impolicy','imscale','im'].forEach(p => u.searchParams.delete(p));
            fullResURL = u.href;
          }
        } catch {
          done++;
          continue;
        }

        try {
          const scaled = await fetchImageAsDataURI(displayURL);

          if (scaled) {
            img.setAttribute('src', scaled.dataURI);

            if (scaled.isGif && scaled.isAnimated) {
              // Animated GIF — no lightbox needed
              img.classList.add('sgs-gif-anim');
            } else {
              // Fetch full-res for lightbox; fall back to scaled if it fails
              if (fullResURL !== displayURL) {
                try {
                  const full = await fetchImageAsDataURI(fullResURL);
                  img.setAttribute('data-full', full ? full.dataURI : scaled.dataURI);
                } catch {
                  img.setAttribute('data-full', scaled.dataURI);
                }
              } else {
                img.setAttribute('data-full', scaled.dataURI);
              }
            }
          }
        } catch (err) {
          console.warn('[SGS] Image fetch failed:', displayURL, err);
        }

        done++;
        setProgress(5 + (done / total) * 82);
        setStatus(`Processing images… (${done} / ${total})`);
      }

      // ── Build TOC ─────────────────────────────────────────────────────────
      setStatus('Building table of contents…');
      const { inlineTOC, floatTOC } = buildTOC(contentClone);

      // ── Assemble HTML ─────────────────────────────────────────────────────
      setStatus('Assembling document…');
      setProgress(90);

      const savedDate = new Date().toLocaleDateString(undefined, {
        year: 'numeric', month: 'long', day: 'numeric'
      });

      const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHTML(titleText)}</title>
<style>${buildOutputCSS()}</style>
</head>
<body>
<div id="sgs-page">

  <div id="sgs-header">
    <h1>${escapeHTML(titleText)}</h1>
    <div class="sgs-meta">${authorText ? escapeHTML(authorText) + ' &nbsp;&middot;&nbsp; ' : ''}Saved from Steam Community &nbsp;&middot;&nbsp; ${escapeHTML(savedDate)}</div>
  </div>

  ${inlineTOC}

  <div id="sgs-content">
    ${contentClone.innerHTML}
  </div>

</div>

<div id="sgs-lightbox" role="dialog" aria-modal="true" aria-label="Image viewer">
  <button id="sgs-lb-close" aria-label="Close">&#x2715;</button>
  <img id="sgs-lightbox-img" src="" alt="Full size image">
</div>

<button id="sgs-float-btn" aria-label="Table of contents" title="Table of contents">&#x2630;</button>

<div id="sgs-float-panel" role="navigation" aria-label="Table of contents">
  ${floatTOC}
</div>

<script>${buildOutputJS()}</script>
</body>
</html>`;

      // ── Download ──────────────────────────────────────────────────────────
      setStatus('Saving…');
      setProgress(97);

      const safeTitle = titleText.replace(/[\\/:*?"<>|]/g, '_').trim() || 'steam_guide';
      const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
      const blobURL = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = blobURL;
      a.download = safeTitle + '.html';
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      setTimeout(() => URL.revokeObjectURL(blobURL), 8000);

      setProgress(100);
      setStatus('Saved!');
      setTimeout(() => {
        setStatus('');
        setProgress(0);
        document.getElementById('sgs-progress-bar-wrap').style.display = 'none';
      }, 4000);

    } catch (err) {
      setStatus('Error: ' + err.message);
      console.error('[SGS]', err);
    } finally {
      btn.disabled = false;
    }
  }

  // ─── Boot ──────────────────────────────────────────────────────────────────

  injectBar();

})();