// ==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);
})();