您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
pixivのブックマークページで、「未分類」のイラストに自動でタグを付けるスクリプトです。シンプルなルールや高度なカスタムルールに基づいてタグを分類し、整理作業を効率化します。
// ==UserScript== // @name pixivブックマーク自動分類スクリプト // @namespace https://greasyfork.org/ja/users/1519380-yofumin // @version 0.1.0 // @description pixivのブックマークページで、「未分類」のイラストに自動でタグを付けるスクリプトです。シンプルなルールや高度なカスタムルールに基づいてタグを分類し、整理作業を効率化します。 // @author yofumin // @match https://www.pixiv.net/users/*/bookmarks/artworks* // @icon data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text x="51" y="91" font-size="118" text-anchor="middle">🅿️</text><text x="77" y="92" font-size="50" text-anchor="middle">⚡️</text></svg> // @grant GM_setValue // @grant GM_getValue // @grant GM_setClipboard // @license MIT // ==/UserScript== (async function() { 'use strict'; const DEFAULT_SETTINGS = { excludedTags: "R-18,漫画", othersTag: "その他", useAdvancedRules: false, advancedRules: `// ルールの例 // 女の子 | 少女 -> キャラクター, 女の子 // 風景 & 夜 -> 夜景, 風景` }; const API_LIMIT = 100; const MAX_LOG_LINES = 7; const UI_CONTAINER_ID = 'auto-classifier-ui'; let settings = {}; function logToPanel(message, type = 'info') { const logPanel = document.getElementById('ac-log-panel'); if (!logPanel) return; const logEntry = document.createElement('p'); logEntry.textContent = message; logEntry.className = `ac-log-entry ac-log-${type}`; logPanel.appendChild(logEntry); while (logPanel.children.length > MAX_LOG_LINES) { logPanel.removeChild(logPanel.firstChild); } logPanel.scrollTop = logPanel.scrollHeight; } const log = (message, type = 'info') => { console.log(`[自動分類スクリプト] ${message}`); logToPanel(message, type); }; const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); async function saveSettings() { const newSettings = { excludedTags: document.getElementById('ac-excluded-tags-input').value, othersTag: document.getElementById('ac-others-tag-input').value, useAdvancedRules: document.getElementById('ac-use-advanced-rules-checkbox').checked, advancedRules: document.getElementById('ac-advanced-rules-textarea').value, }; await GM_setValue('pixivAutoClassifierSettings', newSettings); settings = newSettings; logToPanel("設定を保存しました。", "success"); closeSettingsModal(); } async function loadSettings() { const savedSettings = await GM_getValue('pixivAutoClassifierSettings', {}); settings = { ...DEFAULT_SETTINGS, ...savedSettings }; document.getElementById('ac-excluded-tags-input').value = settings.excludedTags; document.getElementById('ac-others-tag-input').value = settings.othersTag; document.getElementById('ac-use-advanced-rules-checkbox').checked = settings.useAdvancedRules; document.getElementById('ac-advanced-rules-textarea').value = settings.advancedRules; toggleAdvancedRuleUI(settings.useAdvancedRules); } function toggleAdvancedRuleUI(isVisible) { document.getElementById('ac-advanced-rules-container').style.display = isVisible ? 'block' : 'none'; } function openSettingsModal() { document.getElementById('ac-settings-modal').style.display = 'flex'; } function closeSettingsModal() { document.getElementById('ac-settings-modal').style.display = 'none'; } function isUiVisible() { return !!document.getElementById(UI_CONTAINER_ID); } function removeUI() { const uiContainer = document.getElementById(UI_CONTAINER_ID); if (uiContainer) { uiContainer.remove(); } } function createUI() { const uiContainer = document.createElement('div'); uiContainer.id = UI_CONTAINER_ID; uiContainer.innerHTML = ` <div id="ac-controls"> <button id="ac-start-button" class="ac-button">自動分類</button> <div id="ac-settings-icon">⚙️</div> </div> <div id="ac-log-panel"></div> <div id="ac-settings-modal" style="display: none;"> <div id="ac-settings-content"> <h2>自動分類スクリプト設定</h2> <div class="ac-setting-item"> <label for="ac-excluded-tags-input">保持タグ:他に分類出来るタグがない場合、代替タグも付けるタグ:</label> <input type="text" id="ac-excluded-tags-input"> </div> <div class="ac-setting-item"> <label for="ac-others-tag-input">代替タグ:分類不能・ルール不一致時のタグ:</label> <input type="text" id="ac-others-tag-input"> </div> <hr class="ac-hr"> <div class="ac-setting-item"> <input type="checkbox" id="ac-use-advanced-rules-checkbox"> <label for="ac-use-advanced-rules-checkbox">高度なタグ付けルールを使用する</label> </div> <div id="ac-advanced-rules-container" style="display: none;"> <div class="ac-rule-header"> <label>タグ付けルール (1行1ルール):</label> <button id="ac-copy-simple-rules-button" class="ac-button ac-button-small">シンプル分類ルールをコピー</button> </div> <div class="ac-rule-description"> <b>基本:</b> <code>条件タグ -> 追加タグ</code><br> <b>複数追加:</b> <code>条件タグ -> 追加タグ1, 追加タグ2</code><br> <b>OR条件:</b> <code>条件A | 条件B -> 追加タグ</code><br> <b>AND条件:</b> <code>条件X & 条件Y -> 追加タグ</code> </div> <textarea id="ac-advanced-rules-textarea" rows="8"></textarea> </div> <div id="ac-settings-buttons"> <button id="ac-save-button" class="ac-button">保存</button> <button id="ac-close-button" class="ac-button ac-button-secondary">閉じる</button> </div> </div> </div> `; const style = document.createElement('style'); style.textContent = ` #auto-classifier-ui { position: fixed; top: 80px; right: 20px; z-index: 10000; background-color: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.15); display: flex; flex-direction: column; gap: 10px; width: 300px; } #ac-controls { display: flex; align-items: center; justify-content: space-between; } .ac-button { padding: 8px 12px; border: none; border-radius: 4px; color: white; background-color: #007bff; cursor: pointer; font-weight: bold; font-size: 14px; } .ac-button:disabled { background-color: #6c757d; cursor: not-allowed; } .ac-button-secondary { background-color: #6c757d; } #ac-settings-icon { cursor: pointer; font-size: 24px; line-height: 1; } #ac-log-panel { height: 140px; background-color: #f8f9fa; border: 1px solid #e9ecef; border-radius: 4px; padding: 8px; overflow-y: auto; font-size: 12px; line-height: 1.5; color: #333; } .ac-log-entry { margin: 0; padding: 0; } .ac-log-success { color: #28a745; } .ac-log-error { color: #dc3545; font-weight: bold; } #ac-settings-modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); display: flex; justify-content: center; align-items: center; z-index: 10001; } #ac-settings-content { background-color: white; padding: 24px; border-radius: 8px; width: 500px; box-shadow: 0 4px 8px rgba(0,0,0,0.2); } .ac-setting-item { margin-bottom: 16px; } .ac-setting-item label { display: block; margin-bottom: 4px; font-weight: bold; } .ac-setting-item input[type="text"] { width: 95%; padding: 8px; } .ac-setting-item input[type="checkbox"] + label { font-weight: normal; } #ac-settings-buttons { text-align: right; margin-top: 24px; } .ac-hr { border: none; border-top: 1px solid #eee; margin: 20px 0; } .ac-rule-description { font-size: 12px; color: #555; background-color: #f8f9fa; padding: 8px; border-radius: 4px; margin: 4px 0 8px 0; line-height: 1.6; } .ac-rule-description code { background-color: #e9ecef; padding: 2px 4px; border-radius: 3px; font-family: Consolas, monaco, monospace; } #ac-advanced-rules-textarea { width: 95%; padding: 8px; font-family: Consolas, monaco, monospace; } .ac-rule-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; } .ac-button-small { padding: 4px 8px; font-size: 12px; background-color: #6c757d; font-weight: normal; } `; document.head.appendChild(style); document.body.appendChild(uiContainer); } async function initializeUI(userId) { if (isUiVisible()) return; createUI(); await loadSettings(); document.getElementById('ac-start-button').addEventListener('click', () => startClassification(userId)); document.getElementById('ac-settings-icon').addEventListener('click', openSettingsModal); document.getElementById('ac-save-button').addEventListener('click', saveSettings); document.getElementById('ac-close-button').addEventListener('click', closeSettingsModal); document.getElementById('ac-use-advanced-rules-checkbox').addEventListener('change', (e) => { toggleAdvancedRuleUI(e.target.checked); }); document.getElementById('ac-copy-simple-rules-button').addEventListener('click', () => copySimpleRulesToClipboard(userId)); } /** * シンプル分類のルールを生成し、クリップボードにコピーします。 * @param {string} userId */ async function copySimpleRulesToClipboard(userId) { const button = document.getElementById('ac-copy-simple-rules-button'); const originalText = button.textContent; button.disabled = true; button.textContent = 'コピー中...'; try { log('ブックマークタグ一覧を取得しています...'); const myTagsResponse = await fetch(`/ajax/user/${userId}/illusts/bookmark/tags?lang=ja`); if (!myTagsResponse.ok) throw new Error(`タグ一覧取得APIエラー: ${myTagsResponse.status}`); const myTagsData = await myTagsResponse.json(); if (myTagsData.error) throw new Error(`タグ一覧の取得に失敗: ${myTagsData.message}`); const excludedTags = new Set(settings.excludedTags.split(',').map(t => t.trim()).filter(Boolean)); const allMyBookmarkTags = [...myTagsData.body.public, ...myTagsData.body.private].map(item => item.tag); const rules = allMyBookmarkTags .filter(tag => !excludedTags.has(tag)) // 除外タグを除外 .map(tag => `${tag} -> ${tag}`); // 「タグ -> タグ」形式に変換 if (rules.length === 0) { log('コピー対象のタグが見つかりませんでした。', 'error'); button.textContent = '対象なし'; } else { const rulesText = rules.join('\n'); await navigator.clipboard.writeText(rulesText); log(`${rules.length}件のシンプル分類ルールをクリップボードにコピーしました。`, 'success'); button.textContent = 'コピーしました!'; } } catch (error) { log(`[エラー] ルールのコピーに失敗しました: ${error.message}`, 'error'); button.textContent = '失敗'; } finally { setTimeout(() => { button.disabled = false; button.textContent = originalText; }, 2000); // 2秒後にボタンの状態を元に戻す } } function parseAdvancedRules(rulesText) { const parsedRules = []; const lines = rulesText.split('\n').filter(line => !line.trim().startsWith('//') && line.trim() !== '' && line.includes('->')); for (const line of lines) { const [conditionPart, tagsPart] = line.split('->').map(s => s.trim()); if (!conditionPart || !tagsPart) continue; const tagsToAdd = tagsPart.split(',').map(t => t.trim()).filter(Boolean); if (tagsToAdd.length === 0) continue; let conditions = []; let type = 'OR'; if (conditionPart.includes('&')) { conditions = conditionPart.split('&').map(c => c.trim()).filter(Boolean); type = 'AND'; } else { conditions = conditionPart.split('|').map(c => c.trim()).filter(Boolean); type = 'OR'; } if (conditions.length > 0) { parsedRules.push({ conditions, tagsToAdd, type }); } } return parsedRules; } async function startClassification(userId) { const startButton = document.getElementById('ac-start-button'); startButton.disabled = true; try { log("スクリプトを開始します。"); const csrfToken = (() => { const nextData = JSON.parse(document.getElementById('__NEXT_DATA__').textContent); const pageProps = nextData.props.pageProps; return pageProps.token || JSON.parse(pageProps.serverSerializedPreloadedState).api.token; })(); if (!csrfToken) throw new Error("CSRFトークンが取得できませんでした。"); log("自分のブックマークタグ一覧を取得中..."); const myTagsResponse = await fetch(`/ajax/user/${userId}/illusts/bookmark/tags?lang=ja`); if (!myTagsResponse.ok) throw new Error(`タグ一覧取得APIエラー: ${myTagsResponse.status}`); const myTagsData = await myTagsResponse.json(); if (myTagsData.error) throw new Error(`タグ一覧の取得に失敗: ${myTagsData.message}`); const allMyBookmarkTags = new Set([...myTagsData.body.public, ...myTagsData.body.private].map(item => item.tag)); const preservedTags = new Set(settings.excludedTags.split(',').map(t => t.trim()).filter(Boolean)); log("「未分類」のブックマークを取得します..."); const works = await fetchUnclassifiedWorks(userId, csrfToken); if (works.length === 0) { log("分類対象のブックマークが見つかりませんでした。"); return; } log(`合計 ${works.length}件のイラストを分類します。`, "success"); let processedCount = 0; for (const work of works) { processedCount++; startButton.textContent = `分類中...(${processedCount}/${works.length})`; const tagsForThisWork = new Set(); const workTagsSet = new Set(work.tags); if (settings.useAdvancedRules) { const parsedRules = parseAdvancedRules(settings.advancedRules); // 1. ルールに基づいてタグを決定 for (const rule of parsedRules) { let match = false; const conditionCheckTags = new Set([...workTagsSet].filter(t => !preservedTags.has(t))); if (rule.type === 'OR') { match = rule.conditions.some(cond => conditionCheckTags.has(cond)); } else { match = rule.conditions.every(cond => conditionCheckTags.has(cond)); } if (match) { rule.tagsToAdd.forEach(tag => tagsForThisWork.add(tag)); } } // 2. ルールにマッチしなかった場合、代替タグを追加 if (tagsForThisWork.size === 0) { tagsForThisWork.add(settings.othersTag); } } else { const matchedTags = work.tags.filter(tag => allMyBookmarkTags.has(tag) && !preservedTags.has(tag)); if (matchedTags.length > 0) { matchedTags.forEach(tag => tagsForThisWork.add(tag)); } else { tagsForThisWork.add(settings.othersTag); } } // 3. 保持タグを(ルール適用結果に関わらず)追加 preservedTags.forEach(tag => { if (workTagsSet.has(tag)) { tagsForThisWork.add(tag); } }); await addTagsToBookmark(work, [...tagsForThisWork], csrfToken); await sleep(1000); } log("全ての処理が完了しました。", "success"); log("結果を反映するには、手動でページを更新してください。"); } catch (error) { log(`[エラー] ${error.message}`, "error"); alert(`エラーが発生したため処理を中断しました。\n詳細は開発者コンソールとUI上のログを確認してください。`); } finally { if (isUiVisible()) { const startButton = document.getElementById('ac-start-button'); startButton.disabled = false; startButton.textContent = "自動分類"; } } } async function fetchUnclassifiedWorks(userId, csrfToken) { let offset = 0; let allWorks = []; const startButton = document.getElementById('ac-start-button'); while (true) { startButton.textContent = `取得中...(${offset}~)`; const encodedTag = encodeURIComponent("未分類"); const bookmarksApiUrl = `/ajax/user/${userId}/illusts/bookmarks?tag=${encodedTag}&offset=${offset}&limit=${API_LIMIT}&rest=show&lang=ja`; const bookmarksResponse = await fetch(bookmarksApiUrl, { headers: { "accept": "application/json", "referer": location.href, "x-user-id": userId, 'x-csrf-token': csrfToken } }); if (!bookmarksResponse.ok) throw new Error(`ブックマーク一覧取得APIエラー: ${bookmarksResponse.status}`); const bookmarksData = await bookmarksResponse.json(); if (bookmarksData.error) throw new Error(`ブックマーク一覧の取得に失敗: ${bookmarksData.message}`); const works = bookmarksData.body.works; if (!works || works.length === 0) break; log(`${offset}件目から${works.length}件の作品を取得しました。`); allWorks.push(...works); offset += works.length; if (works.length < API_LIMIT) break; await sleep(500); } return allWorks; } async function addTagsToBookmark(work, tags, csrfToken) { if (tags.length === 0) { log(`「${work.title}」に追加するタグがありませんでした。スキップします。`); return; } const addTagsResponse = await fetch('/ajax/illusts/bookmarks/add_tags', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken, "referer": location.href }, body: JSON.stringify({ bookmarkIds: [work.bookmarkData.id], tags: tags }) }); if (!addTagsResponse.ok) { log(`「${work.title}」のタグ付けでHTTPエラー: ${addTagsResponse.status}`, "error"); return; } const result = await addTagsResponse.json(); if (result.error) { log(`「${work.title}」でエラー: ${result.message}`, "error"); } else { log(`「${work.title}」にタグ「${tags.join(', ')}」を追加しました。`, "success"); } } function checkUrlAndToggleUI() { try { const pageOwnerIdMatch = location.pathname.match(/\/users\/(\d+)\/bookmarks\/artworks/); const pageOwnerId = pageOwnerIdMatch ? pageOwnerIdMatch[1] : null; if (!pageOwnerId) { removeUI(); return; } const nextDataElement = document.getElementById('__NEXT_DATA__'); if (!nextDataElement) { removeUI(); return; } const nextData = JSON.parse(nextDataElement.textContent); const loggedInUserId = nextData?.props?.pageProps?.gaUserData?.userId; if (loggedInUserId && pageOwnerId === loggedInUserId) { initializeUI(loggedInUserId); } else { removeUI(); } } catch (error) { console.error('[自動分類スクリプト] URLチェック中にエラー:', error); removeUI(); } } const originalPushState = history.pushState; history.pushState = function(...args) { const result = originalPushState.apply(this, args); window.dispatchEvent(new Event('pushstate')); return result; }; checkUrlAndToggleUI(); window.addEventListener('pushstate', checkUrlAndToggleUI); window.addEventListener('popstate', checkUrlAndToggleUI); })();