HTML Source Downloader

現在のページのHTMLを整形してUTF-8で保存。

Version au 19/08/2025. Voir la dernière version.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         HTML Source Downloader
// @namespace    https://bsky.app/profile/neon-ai.art
// @homepage     https://bsky.app/profile/neon-ai.art
// @icon         data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⛄️</text></svg>
// @description  現在のページのHTMLを整形してUTF-8で保存。
// @author       ねおん
// @version      3.0
// @match        *://*/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @license      CC BY-NC 4.0
// ==/UserScript==

(function () {
  'use strict';

  const VERSION = 'v3.0';
  if (window.top !== window.self) return; // サブフレームでは動かさない

  // ========= 設定 =========
  const STORE_KEY = 'html_source_dl__shortcut';
  let userShortcut = GM_getValue(STORE_KEY, 'Alt+Shift+S'); // 全ドメイン共通

  // ========= ユーティリティ =========
  const VOID_RE = /^(?:area|base|br|col|embed|hr|img|input|keygen|link|meta|param|source|track|wbr)\b/i;

  function normalizeShortcutString(s) {
    if (!s) return 'Alt+Shift+S';
    s = String(s).trim().replace(/\s+/g, '');
    // 分解(+区切り、修飾キーは大小不問)
    const parts = s.split('+').map(p => p.toLowerCase());
    const mods = new Set();
    let main = '';
    for (const p of parts) {
      if (['ctrl', 'control'].includes(p)) mods.add('Ctrl');
      else if (['alt', 'option'].includes(p)) mods.add('Alt');
      else if (['shift'].includes(p)) mods.add('Shift');
      else if (['meta', 'cmd', 'command', '⌘'].includes(p)) mods.add('Meta');
      else main = p;
    }
    // メインキーを大文字1文字 or F1.. の形に
    if (!main) main = 'S';
    if (/^key[a-z]$/i.test(main)) main = main.slice(3); // "KeyK"
    if (/^digit[0-9]$/i.test(main)) main = main.slice(5); // "Digit1"
    if (/^[a-z]$/.test(main)) main = main.toUpperCase();
    if (/^f([1-9]|1[0-2])$/i.test(main)) main = main.toUpperCase();
    // 記号などはそのまま(例: Slash, Backquote などの code 名)
    const order = ['Ctrl', 'Shift', 'Alt', 'Meta'];
    const modStr = order.filter(m => mods.has(m)).join('+');
    return (modStr ? modStr + '+' : '') + main;
  }

  function eventMatchesShortcut(e, shortcut) {
    const norm = normalizeShortcutString(shortcut);
    const parts = norm.split('+');
    const mods = new Set(parts.slice(0, -1));
    const keyPart = parts[parts.length - 1];

    const need = {
      Ctrl: mods.has('Ctrl'),
      Shift: mods.has('Shift'),
      Alt: mods.has('Alt'),
      Meta: mods.has('Meta'),
    };
    if (need.Ctrl !== e.ctrlKey) return false;
    if (need.Shift !== e.shiftKey) return false;
    if (need.Alt !== e.altKey) return false;
    if (need.Meta !== e.metaKey) return false;

    // メインキー判定(英数字は e.code 基準)
    const main = keyPart;
    let pressed = '';
    if (e.code.startsWith('Key')) pressed = e.code.slice(3).toUpperCase();
    else if (e.code.startsWith('Digit')) pressed = e.code.slice(5);
    else pressed = e.key.length === 1 ? e.key.toUpperCase() : e.key; // F1 などは e.key

    return pressed === main;
  }

  // ========= ダウンロード本体 =========
  function downloadHTML() {
    try {
      const d = document;
      const dt = d.doctype
        ? `<!DOCTYPE ${d.doctype.name}${
            d.doctype.publicId ? ` PUBLIC "${d.doctype.publicId}"` : ''
          }${!d.doctype.publicId && d.doctype.systemId ? ' SYSTEM' : ''}${
            d.doctype.systemId ? ` "${d.doctype.systemId}"` : ''
          }>\n`
        : '';

      let html = dt + d.documentElement.outerHTML;

      // meta charset を UTF-8 に統一
      if (/<meta[^>]*charset\s*=\s*["']?[^"'>\s]+["']?[^>]*>/i.test(html)) {
        html = html.replace(
          /<meta[^>]*charset\s*=\s*["']?[^"'>\s]+["']?[^>]*>/i,
          '<meta charset="UTF-8">'
        );
      } else if (/<head[^>]*>/i.test(html)) {
        html = html.replace(/<head[^>]*>/i, '$&<meta charset="UTF-8">');
      } else {
        html = '<meta charset="UTF-8">' + html;
      }

      // 整形
      html = (p => {
        let i = 0;
        return p
          .replace(/>\s*</g, '><')
          .replace(/></g, '>\n<')
          .split('\n')
          .map(l => {
            if (/^<\//.test(l) && !/.*<\/.+>.*<.+>/.test(l)) i = Math.max(i - 1, 0);
            const r = '  '.repeat(Math.max(i, 0)) + l;
            if (
              /^<[^!?/]/.test(l) &&
              !/<.+<\/.+>/.test(l) &&
              !/\/>$/.test(l) &&
              !VOID_RE.test(l)
            ){
            i++;
            } return r;
          })
          .join('\n');
      })(html);

      // ファイル名
      const pad = n => String(n).padStart(2, '0');
      const now = new Date();
      const ts = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(
        now.getDate()
      )}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(
        now.getSeconds()
      )}`;
      const path =
        (location.pathname || '/')
          .replace(/\/+/g, '/')
          .replace(/[^a-z0-9\-_.\/]/gi, '_')
          .replace(/^\/|\/$/g, '')
          .replace(/\//g, '_') || 'index';
      const name = (location.hostname || 'page') + '_' + path + '_' + ts + '.html';

      // ダウンロード
      try {
        const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
        const a = d.createElement('a');
        a.download = name;
        a.href = URL.createObjectURL(blob);
        (d.body || d.documentElement).appendChild(a);
        a.click();
        setTimeout(() => {
          URL.revokeObjectURL(a.href);
          a.remove();
        }, 1000);
      } catch (err) {
        // 失敗時は別タブ(data:)
        const url = 'data:text/html;charset=utf-8,' + encodeURIComponent(html);
        const w = window.open(url);
        if (!w) alert('ポップアップがブロックされました。許可してからもう一度試してね');
      }
    } catch (e) {
      alert('Failed: ' + e);
    }
  }

  // ========= 設定UI =========
function ensureStyle() {
    if (document.getElementById('hsd-style')) return;
    const style = document.createElement('style');
    style.id = 'hsd-style';
    style.textContent = `
    :root {
      --bg-color: #1a1a1a;
      --text-color: #f0f0f0;
      --border-color: #333;
      --primary-color: #007bff;
      --primary-hover: #0056b3;
      --secondary-color: #343a40;
      --modal-bg: #212529;
      --shadow: 0 8px 16px rgba(0, 0, 0, 0.5);
      --border-radius: 12px;
    }
    .hsd-overlay {
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background-color: rgba(0, 0, 0, 0.7);
      z-index: 100000;
      display: flex;
      justify-content: center;
      align-items: center;
    }
    .hsd-panel {
      background-color: var(--modal-bg);
      color: var(--text-color);
      width: 90%;
      max-width: 400px;
      border-radius: var(--border-radius);
      box-shadow: var(--shadow);
      border: 1px solid var(--border-color);
      font-family: 'Inter', sans-serif;
      overflow: hidden;
    }
    .hsd-title {
      padding: 15px 20px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      border-bottom: 1px solid var(--border-color);
      font-size: 1.25rem;
      font-weight: 600;
      margin: 0;
    }
    .hsd-close {
      background: none;
      border: none;
      cursor: pointer;
      font-size: 24px;
      color: var(--text-color);
      opacity: 0.7;
      padding: 0;
    }
    .hsd-close:hover {
      opacity: 1;
    }
    .hsd-section {
      padding: 20px;
    }
    .hsd-label {
      font-size: 1rem;
      font-weight: 500;
      color: #e0e0e0;
      display: block;
      margin-bottom: 8px;
    }
    .hsd-input {
      width: 100%;
      padding: 8px 12px;
      background-color: var(--secondary-color);
      color: var(--text-color);
      border: 1px solid var(--border-color);
      border-radius: 6px;
      cursor: text;
      box-sizing: border-box;
    }
    .hsd-input:focus {
      border-color: var(--primary-color);
      box-shadow: 0 0 4px var(--primary-color);
    }
    .hsd-bottom {
      padding: 15px 20px;
      border-top: 1px solid var(--border-color);
      display: flex;
      justify-content: space-between;
      align-items: center;
    }
    .hsd-bottom .hsd-version {
      font-size: 0.8rem;
      font-weight: 400;
      color: #aaa;
    }
    .hsd-button {
      padding: 10px 20px;
      border: none;
      border-radius: 6px;
      font-weight: bold;
      cursor: pointer;
      transition: all 0.2s ease;
      background-color: var(--primary-color);
      color: white;
    }
    .hsd-button:hover {
      background-color: var(--primary-hover);
    }
    `;
    document.head.appendChild(style);
  }

  function showToast(msg) {
    const toast = document.createElement('div');
    toast.textContent = msg;
    toast.style.cssText = `
      position: fixed; bottom: 20px; left: 50%;
      transform: translateX(-50%);
      background: var(--primary-color);
      color: white; padding: 10px 20px;
      border-radius: 6px;
      z-index: 100000;
      font-size: 14px;
    `;
    document.body.appendChild(toast);
    setTimeout(() => toast.remove(), 2000);
  }

  function openSettings() {
    ensureStyle();

    // モーダル背景
    const overlay = document.createElement('div');
    overlay.className = 'hsd-overlay';

    // モーダル本体
    const panel = document.createElement('div');
    panel.className = 'hsd-panel';

    // 閉じるボタン
    const closeBtn = document.createElement('span');
    closeBtn.className = 'hsd-close';
    closeBtn.textContent = '×';
    closeBtn.title = '閉じる';
    closeBtn.addEventListener('click', () => document.body.removeChild(overlay));

    // タイトルバー
    const title = document.createElement('div');
    title.className = 'hsd-title';
    title.textContent = '設定';
    title.appendChild(closeBtn);

    // 設定セクション
    const section = document.createElement('div');
    section.className = 'hsd-section';

    const label = document.createElement('div');
    label.className = 'hsd-label';
    label.textContent = 'ショートカットキー';

    const input = document.createElement('input');
    input.type = 'text';
    input.className = 'hsd-input';
    input.placeholder = '例: Ctrl+Shift+K';
    input.setAttribute("inputmode", "latin");
    input.setAttribute("lang", "en");
    input.inputMode = 'latin';
    input.style.imeMode = "disabled";
    input.readOnly = true;
    input.addEventListener('input', () => {
      // 全角英数字→半角英数字に変換
      input.value = input.value.replace(/[A-Za-z0-9]/g, s =>
        String.fromCharCode(s.charCodeAt(0) - 0xFEE0)
      );
      // 全角記号や日本語を削除
      input.value = input.value.replace(/[^\x00-\x7F]/g, '');
    });
    input.value = normalizeShortcutString(userShortcut);

    // キーキャプチャ(小文字入力でも大文字表示)
    input.addEventListener('keydown', e => {
      e.preventDefault(); // IME入力や全角候補を完全ブロック

      const mods = [];
      if (e.ctrlKey) mods.push('Ctrl');
      if (e.shiftKey) mods.push('Shift');
      if (e.altKey) mods.push('Alt');
      if (e.metaKey) mods.push('Meta');

      let main = '';
      if (e.code.startsWith('Key')) main = e.code.slice(3).toUpperCase();
      else if (e.code.startsWith('Digit')) main = e.code.slice(5);
      else if (/^F[1-9]|F1[0-2]$/.test(e.key)) main = e.key.toUpperCase();
      else if (e.key && e.key.length === 1) main = e.key.toUpperCase();

      // ここで value を上書き
      input.value = (mods.length ? mods.join('+') + '+' : '') + main;
    });

    section.appendChild(label);
    section.appendChild(input);

    // フッター(バージョン & 保存ボタン)
    const bottom = document.createElement('div');
    bottom.className = 'hsd-bottom';

    const version = document.createElement('div');
    version.className = 'hsd-version';
    version.textContent = '(' + VERSION + ')';

    const saveBtn = document.createElement('button');
    saveBtn.className = 'hsd-button';
    saveBtn.textContent = '保存';
    saveBtn.addEventListener('click', () => {
      const norm = normalizeShortcutString(input.value);
      userShortcut = norm;
      GM_setValue(STORE_KEY, userShortcut); // 全ドメイン共通保存
      document.body.removeChild(overlay);
      showToast('設定を保存しました!');
    });

    bottom.appendChild(version);
    bottom.appendChild(saveBtn);

    // モーダル背景クリックで閉じる
    overlay.addEventListener('click', e => {
      if (e.target === overlay) overlay.remove();
    });

    // ESCキーで閉じる
    document.addEventListener('keydown', e => {
      if (e.key === 'Escape') overlay.remove();
    }, { once: true });


    // 組み立て
    panel.appendChild(title);
    panel.appendChild(section);
    panel.appendChild(bottom);
    overlay.appendChild(panel);
    document.body.appendChild(overlay);

    input.focus();
  }

  // ========= イベント / メニュー =========
  // メニュー(実行 & 設定)
  GM_registerMenuCommand('HTMLをダウンロード', downloadHTML);
  GM_registerMenuCommand('設定', openSettings);

  // ショートカット実行
  document.addEventListener('keydown', e => {
    // 入力欄でのタイプは無視(フォーム操作の邪魔をしない)
    const tag = (e.target && e.target.tagName) || '';
    if (/(INPUT|TEXTAREA|SELECT)/.test(tag)) return;
    if (eventMatchesShortcut(e, userShortcut)) {
      e.preventDefault();
      downloadHTML();
    }
  });
})();