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.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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();

})();