// ==UserScript== // @name WIKIWIKI 編集ソース保存&添付画像一括ダウンロード // @namespace local.wikiwiki.backup // @version 2.0 // @description wikiwiki.jpの編集画面(::cmd/edit)のソースをtxt保存、添付ファイル一覧(::cmd/attach)の画像を一括ダウンロードします。 // @match https://wikiwiki.jp/* // @run-at document-idle // @grant none // ==/UserScript== (function () { 'use strict'; function isEditPage() { return /::cmd\/edit/.test(location.pathname) || /cmd=edit/.test(location.search); } function isAttachPage() { return /::cmd\/attach/.test(location.pathname) || /cmd=attach/.test(location.search); } if (isEditPage()) { initEditPageSaver(); } else if (isAttachPage()) { initAttachPageDownloader(); } // ================= 共通: ページ上部に余白を追加 ================= function addTopPadding(px) { const style = document.createElement('style'); style.textContent = `body { padding-top: ${px}px !important; }`; document.head.appendChild(style); } // ================= 編集ページ: ソース保存 ================= function initEditPageSaver() { addTopPadding(60); // URLからページ名(page=の値)を取り出す。取れない場合はdocument.titleなどから推測。 function getPageNameFromUrl() { const url = new URL(location.href); const pageParam = url.searchParams.get('page'); if (pageParam) { return decodeURIComponent(pageParam); } // ::cmd/edit?page=xxx 形式だが、pathnameの末尾に付くケースも一応拾う const match = decodeURIComponent(location.href).match(/[?&]page=([^&]+)/); if (match) { return match[1]; } return 'untitled'; } // ファイル名として使えない文字を置換 function sanitizeFileName(name) { return name .replace(/[\\/:*?"<>|]/g, '_') .trim() || 'untitled'; } // 編集用テキストエリアを探す。 // PukiWiki系wikiは name="msg" のtextareaが本文ソースであることが多いが、 // 念のため複数候補から一番大きい(=本文らしい)textareaを選ぶフォールバックも用意する。 function findSourceTextarea() { const candidates = Array.from(document.querySelectorAll('textarea')); if (candidates.length === 0) return null; // よくある name/id を優先的に探す const preferredNames = ['msg', 'contents', 'text', 'body']; for (const n of preferredNames) { const found = candidates.find( (el) => el.name === n || el.id === n ); if (found) return found; } // 優先候補が無ければ、最も文字数が多い(=本文の可能性が高い)ものを選ぶ candidates.sort((a, b) => (b.value?.length || 0) - (a.value?.length || 0)); return candidates[0]; } function downloadTextFile(filename, text) { const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function createSaveButton() { const btn = document.createElement('button'); btn.textContent = '📝 このページのソースをtxt保存'; btn.type = 'button'; btn.style.position = 'fixed'; btn.style.top = '10px'; btn.style.left = '10px'; btn.style.zIndex = '999999'; btn.style.padding = '10px 14px'; btn.style.background = '#2d7dd2'; btn.style.color = '#fff'; btn.style.border = 'none'; btn.style.borderRadius = '6px'; btn.style.fontSize = '14px'; btn.style.cursor = 'pointer'; btn.style.boxShadow = '0 2px 6px rgba(0,0,0,0.3)'; btn.addEventListener('click', () => { const textarea = findSourceTextarea(); if (!textarea) { alert('本文のテキストエリアが見つかりませんでした。ページ構造が想定と異なる可能性があります。'); return; } const pageName = sanitizeFileName(getPageNameFromUrl()); const content = textarea.value; downloadTextFile(`${pageName}.txt`, content); }); document.body.appendChild(btn); } createSaveButton(); } // ================= 添付ファイル一覧ページ: 画像一括ダウンロード ================= function initAttachPageDownloader() { addTopPadding(140); // URLの page= パラメータから階層名を取得し、ファイル名prefix用に整形する // 例: 用語/人物/た行 -> 用語_人物_た行 function getPagePrefix() { const url = new URL(location.href); const pageParam = url.searchParams.get('page'); if (!pageParam) return 'unknown'; const decoded = decodeURIComponent(pageParam); return decoded .split('/') .map(sanitizeFileNamePart) .join('_'); } function sanitizeFileNamePart(name) { return name.replace(/[\\/:*?"<>|]/g, '_').trim(); } // 添付ファイル一覧テーブルから、有効な(削除されていない)ファイルの // { url, filename } の配列を作る function collectAttachments() { const results = []; // 添付テーブルの行を走査。有効ファイルは attach_td1 内に を持つ const rows = document.querySelectorAll('table.attach_table tbody tr, table.attach_table tr'); rows.forEach((row) => { // ストライプ表示のため、行によってクラスが attach_td1 / attach_td2 と交互になる。 // どちらのクラスでも1列目(ファイル名セル)を拾えるようにする。 const firstCell = row.querySelector('td.attach_td1, td.attach_td2') || row.querySelector('td:first-child'); if (!firstCell) return; const link = firstCell.querySelector('a[href]'); if (!link) return; // 削除済み(リンクなし)はスキップ const href = link.getAttribute('href'); if (!href) return; // リンクのテキストがファイル名。前後の空白や余分な装飾を除去 let filename = link.textContent.trim(); if (!filename) { // テキストが取れない場合はURLの末尾から推測 try { filename = decodeURIComponent(href.split('/').pop().split('?')[0]); } catch (e) { filename = 'file'; } } results.push({ url: href, filename }); }); return results; } function getExtension(filename) { const m = filename.match(/\.([a-zA-Z0-9]+)$/); return m ? m[1] : ''; } function buildOutputFilename(prefix, originalFilename) { // 元のファイル名から拡張子を分離し、 // 「階層_親ページ名_画像ファイル名.拡張子」形式に組み立てる const ext = getExtension(originalFilename); const base = ext ? originalFilename.slice(0, -(ext.length + 1)) : originalFilename; const safeBase = sanitizeFileNamePart(base); return ext ? `${prefix}_${safeBase}.${ext}` : `${prefix}_${safeBase}`; } async function downloadOne(item, outputFilename) { try { const res = await fetch(item.url, { credentials: 'omit' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const blob = await res.blob(); const objectUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = objectUrl; a.download = outputFilename; document.body.appendChild(a); a.click(); document.body.removeChild(a); // Blob URLの解放はダウンロード開始が確実に走った後にする setTimeout(() => URL.revokeObjectURL(objectUrl), 5000); return { ok: true }; } catch (err) { console.warn('[WIKIWIKI保存] fetch失敗、フォールバックを試みます:', item.url, err); // fetch失敗(CORS等)の場合、同一タブ内でdownload属性付きリンクを直接踏む。 // target="_blank"は新規タブ扱いになりポップアップブロックの対象になりやすいため使わない。 try { const a = document.createElement('a'); a.href = item.url; a.download = outputFilename; a.rel = 'noopener'; document.body.appendChild(a); a.click(); document.body.removeChild(a); return { ok: true, fallback: true }; } catch (err2) { console.error('[WIKIWIKI保存] フォールバックも失敗:', item.url, err2); return { ok: false, error: err2 }; } } } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function createPanel() { const panel = document.createElement('div'); panel.style.position = 'fixed'; panel.style.top = '10px'; panel.style.left = '10px'; panel.style.zIndex = '999999'; panel.style.background = '#222'; panel.style.color = '#fff'; panel.style.padding = '12px 16px'; panel.style.borderRadius = '8px'; panel.style.boxShadow = '0 2px 8px rgba(0,0,0,0.4)'; panel.style.fontSize = '13px'; panel.style.maxWidth = '280px'; panel.style.fontFamily = 'sans-serif'; const btn = document.createElement('button'); btn.textContent = '🖼 添付画像を一括ダウンロード'; btn.type = 'button'; btn.style.padding = '8px 12px'; btn.style.background = '#2d7dd2'; btn.style.color = '#fff'; btn.style.border = 'none'; btn.style.borderRadius = '6px'; btn.style.cursor = 'pointer'; btn.style.fontSize = '14px'; btn.style.width = '100%'; const status = document.createElement('div'); status.style.marginTop = '8px'; status.style.whiteSpace = 'pre-wrap'; status.style.lineHeight = '1.4'; const intervalLabel = document.createElement('label'); intervalLabel.style.display = 'block'; intervalLabel.style.marginTop = '8px'; intervalLabel.textContent = 'ダウンロード間隔(ミリ秒): '; const intervalInput = document.createElement('input'); intervalInput.type = 'number'; intervalInput.value = '800'; intervalInput.min = '200'; intervalInput.step = '100'; intervalInput.style.width = '80px'; intervalInput.style.marginLeft = '4px'; intervalLabel.appendChild(intervalInput); panel.appendChild(btn); panel.appendChild(intervalLabel); panel.appendChild(status); document.body.appendChild(panel); btn.addEventListener('click', async () => { const attachments = collectAttachments(); if (attachments.length === 0) { status.textContent = '有効な添付ファイルが見つかりませんでした。'; return; } const prefix = getPagePrefix(); const intervalMs = Math.max(200, parseInt(intervalInput.value, 10) || 800); btn.disabled = true; btn.textContent = 'ダウンロード中...'; let fetchOkCount = 0; let fallbackCount = 0; const failedItems = []; for (let i = 0; i < attachments.length; i++) { const item = attachments[i]; const outputFilename = buildOutputFilename(prefix, item.filename); status.textContent = `(${i + 1}/${attachments.length}) ${outputFilename}`; const result = await downloadOne(item, outputFilename); if (result.ok && !result.fallback) { fetchOkCount++; } else if (result.ok && result.fallback) { fallbackCount++; } else { failedItems.push(outputFilename); } if (i < attachments.length - 1) { await sleep(intervalMs); } } btn.disabled = false; btn.textContent = '🖼 添付画像を一括ダウンロード'; let summary = `完了(全${attachments.length}件)\nfetch成功: ${fetchOkCount}件\nフォールバック: ${fallbackCount}件\n失敗: ${failedItems.length}件`; if (failedItems.length > 0) { summary += `\n\n[失敗したファイル]\n${failedItems.join('\n')}`; } status.textContent = summary; console.log('[WIKIWIKI保存] ダウンロード結果まとめ:', { total: attachments.length, fetchOkCount, fallbackCount, failedItems, }); }); } createPanel(); } })();