Nihe
Niheのユーザーページ(UserScript置き場・Claude管理)
タグスイッチ: ページ右下の🏷️ボタンをタップ→タグ一覧(使用ページ数が多い順)→タップでページ末尾に追加。
code:script.js
// タグスイッチ本体(下のtagswitch.js)を読み込む
import '/api/code/rebma/Nihe/tagswitch.js';
code:tagswitch.js
// タグスイッチ: ワンタップでページ末尾に #タグ を追加するUserScript
window.__nihe_tagswitch = true; // ←v1を無効化(本体は下のv2ブロック)
(() => {
if (window.__nihe_tagswitch) return;
window.__nihe_tagswitch = true;
// ===== 設定 =====
const CACHE_MS = 6 * 60 * 60 * 1000; // タグ集計キャッシュ(6時間)
const MAX_TAGS = 60; // パネルに並べる最大タグ数
const MIN_COUNT = 2; // 使用ページ数がこれ未満のタグは非表示
const EXCLUDE = 'member'; // 表示しないタグ
const TAG_RE = /(?:^|\s )#([^\s #\\]+)/g;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ===== タグ集計 =====
const cacheKey = () => 'tagswitch:' + scrapbox.Project.name;
async function countByExport(project) {
const res = await fetch('/api/page-data/export/' + project + '.json');
if (!res.ok) throw new Error('export ' + res.status);
const data = await res.json();
const counts = new Map();
for (const page of data.pages || []) {
const seen = new Set();
for (const raw of page.lines || []) {
const text = typeof raw === 'string' ? raw : (raw && raw.text) || '';
for (const m of text.matchAll(TAG_RE)) seen.add(m1);
}
for (const t of seen) counts.set(t, (counts.get(t) || 0) + 1);
}
if (counts.size === 0) throw new Error('no tags in export');
return counts;
}
async function countByLinks(project) {
const counts = new Map();
let followingId = '';
for (let i = 0; i < 30; i++) {
const url = '/api/pages/' + project + '/search/titles' + (followingId ? '?followingId=' + followingId : '');
const res = await fetch(url);
if (!res.ok) break;
const pages = await res.json();
for (const p of pages) for (const l of p.links || []) counts.set(l, (counts.get(l) || 0) + 1);
followingId = res.headers.get('X-Following-Id') || '';
if (!followingId) break;
}
return counts;
}
async function loadTags(force) {
if (!force) {
try {
const cached = JSON.parse(localStorage.getItem(cacheKey()) || 'null');
if (cached && Date.now() - cached.ts < CACHE_MS) return cached.tags;
} catch (e) { /* キャッシュ破損は無視 */ }
}
let counts;
try {
counts = await countByExport(scrapbox.Project.name);
} catch (e) {
counts = await countByLinks(scrapbox.Project.name);
}
const tags = ...counts.entries()
.filter((t, c) => c >= MIN_COUNT && !EXCLUDE.includes(t))
.sort((a, b) => b1 - a1)
.slice(0, MAX_TAGS);
try {
localStorage.setItem(cacheKey(), JSON.stringify({ ts: Date.now(), tags }));
} catch (e) { /* 保存失敗は無視 */ }
return tags;
}
// ===== タグ挿入 =====
function hasTag(tag) {
const lines = scrapbox.Page.lines || [];
const esc = tag.replace(/[.*+?^${}()|\\\]/g, '\\$&');
const re = new RegExp('(^|\\s\\u3000)#' + esc + '($|\\s\\u3000)');
return lines.some((l) => re.test(l.text));
}
async function insertTag(tag) {
const lines = scrapbox.Page.lines;
if (!lines || lines.length === 0) return false;
const last = lineslines.length - 1;
const lastEl = document.getElementById('L' + last.id);
if (!lastEl) return false;
// 最終行の右端をクリックしてカーソルを行末に置く
const rect = lastEl.getBoundingClientRect();
const opt = { bubbles: true, cancelable: true, view: window, button: 0, clientX: rect.right - 2, clientY: rect.top + rect.height / 2 };
lastEl.dispatchEvent(new MouseEvent('mousedown', opt));
lastEl.dispatchEvent(new MouseEvent('mouseup', opt));
lastEl.dispatchEvent(new MouseEvent('click', opt));
await sleep(100);
const input = document.getElementById('text-input');
if (!input) return false;
input.focus();
const endKey = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'End', code: 'End' });
Object.defineProperty(endKey, 'keyCode', { get: () => 35 });
Object.defineProperty(endKey, 'which', { get: () => 35 });
input.dispatchEvent(endKey);
await sleep(50);
let text;
if (last.text.trim() === '') text = '#' + tag;
else if (/^(\s*#^\s +)+\s*$/.test(last.text)) text = ' #' + tag;
else text = '\n#' + tag;
input.value = text;
input.dispatchEvent(new InputEvent('input', { bubbles: true }));
setTimeout(() => input.blur(), 150); // スマホのキーボードを閉じる
return true;
}
// ===== UI =====
const style = document.createElement('style');
style.textContent = [
'#tagswitch-fab { position: fixed; right: 14px; bottom: 84px; z-index: 1050; width: 48px; height: 48px; border-radius: 50%; border: none; background: #2d9c67; color: #fff; font-size: 22px; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,.35); display: none; }',
'#tagswitch-panel { position: fixed; right: 10px; bottom: 140px; z-index: 1050; width: min(320px, calc(100vw - 20px)); max-height: 55vh; overflow-y: auto; background: #fff; color: #333; border-radius: 10px; padding: 10px; box-shadow: 0 4px 16px rgba(0,0,0,.35); display: none; }',
'#tagswitch-panel .ts-head { display: flex; gap: 6px; align-items: center; margin-bottom: 8px; }',
'#tagswitch-panel input { flex: 1; min-width: 0; padding: 6px 8px; border: 1px solid #ccc; border-radius: 6px; font-size: 14px; background: #fff; color: #333; }',
'#tagswitch-panel .ts-btn { border: none; background: #eee; border-radius: 6px; padding: 6px 9px; cursor: pointer; font-size: 14px; }',
'#tagswitch-panel .ts-chips { display: flex; flex-wrap: wrap; gap: 6px; }',
'#tagswitch-panel .ts-chip { border: none; border-radius: 14px; padding: 7px 11px; background: #f0f0f0; color: #333; font-size: 13px; cursor: pointer; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
'#tagswitch-panel .ts-chip b { font-weight: 600; }',
'#tagswitch-panel .ts-chip small { color: #888; margin-left: 4px; font-size: 11px; }',
'#tagswitch-panel .ts-chip.on { background: #2d9c67; color: #fff; }',
'#tagswitch-panel .ts-chip.on small { color: #d8f0e4; }',
'#tagswitch-panel .ts-msg { color: #888; font-size: 13px; padding: 4px; }'
].join('\n');
document.head.appendChild(style);
const fab = document.createElement('button');
fab.id = 'tagswitch-fab';
fab.textContent = '🏷️';
const panel = document.createElement('div');
panel.id = 'tagswitch-panel';
panel.innerHTML = '<div class="ts-head"><input type="text" id="tagswitch-filter" placeholder="絞り込み"><button class="ts-btn" id="tagswitch-reload" title="タグ一覧を再集計">🔄</button><button class="ts-btn" id="tagswitch-close">✕</button></div><div class="ts-chips" id="tagswitch-chips"></div>';
document.body.appendChild(fab);
document.body.appendChild(panel);
const chipsEl = panel.querySelector('#tagswitch-chips');
const filterEl = panel.querySelector('#tagswitch-filter');
let tagData = [];
let panelOpen = false;
function setMsg(msg) {
chipsEl.textContent = '';
const d = document.createElement('div');
d.className = 'ts-msg';
d.textContent = msg;
chipsEl.appendChild(d);
}
function renderChips() {
const q = (filterEl.value || '').trim().toLowerCase();
const items = tagData.filter((t) => !q || t.toLowerCase().includes(q));
chipsEl.textContent = '';
if (items.length === 0) {
setMsg('タグが見つかりません');
return;
}
for (const tag, count of items) {
const b = document.createElement('button');
b.className = 'ts-chip' + (hasTag(tag) ? ' on' : '');
const name = document.createElement('b');
name.textContent = '#' + tag;
const num = document.createElement('small');
num.textContent = String(count);
b.appendChild(name);
b.appendChild(num);
b.addEventListener('click', async () => {
if (hasTag(tag)) return;
const ok = await insertTag(tag);
if (!ok) console.warn('tagswitch: 挿入に失敗', tag);
setTimeout(renderChips, 400);
});
chipsEl.appendChild(b);
}
}
async function openPanel(force) {
panelOpen = true;
panel.style.display = 'block';
setMsg('タグを集計中…');
try {
tagData = await loadTags(force);
renderChips();
} catch (e) {
setMsg('タグ集計に失敗しました');
console.warn('tagswitch:', e);
}
}
function closePanel() {
panelOpen = false;
panel.style.display = 'none';
}
fab.addEventListener('click', () => { panelOpen ? closePanel() : openPanel(false); });
panel.querySelector('#tagswitch-close').addEventListener('click', closePanel);
panel.querySelector('#tagswitch-reload').addEventListener('click', () => openPanel(true));
filterEl.addEventListener('input', renderChips);
// ページ表示中だけ🏷️ボタンを出す
setInterval(() => {
const onPage = scrapbox.Layout === 'page';
fab.style.display = onPage ? 'block' : 'none';
if (!onPage && panelOpen) closePanel();
}, 800);
})();
code:tagswitch.js
// ===== v2: 服/映像/その他のグループ表示(同名コードブロックは連結して実行される) =====
(() => {
if (window.__nihe_tagswitch_v2) return;
window.__nihe_tagswitch_v2 = true;
// v1のUIが生成されていたら撤去する
for (const id of 'tagswitch-fab', 'tagswitch-panel') {
const old = document.getElementById(id);
if (old) old.remove();
}
for (const s of Array.from(document.querySelectorAll('style'))) {
if (s.textContent.indexOf('#tagswitch-fab') >= 0) s.remove();
}
// ===== 設定 =====
const CACHE_MS = 6 * 60 * 60 * 1000; // タグ集計キャッシュ(6時間)
const MAX_TAGS = 80; // パネルに並べる最大タグ数
const MIN_COUNT = 2; // 使用ページ数がこれ未満のタグは非表示
const EXCLUDE = 'member'; // 表示しないタグ
// グループ定義: 上から順に表示。exact=完全一致(小文字)、re=キーワード推定。どれにも該当しなければ最後のグループ
const GROUPS = [
{ name: '👗 服', exact: 'fashion', 'brand', 'designer', 'store', 'shop', 'collection', 'commedesgarcons', 'accesary', 'accessory', 'used', 'material', 'screenprinting', 'textile', 'korea', re: /服|ファッション|コーデ|古着|生地|織|縫|柄|素材|アクセ|ブランド|デザイナ|コレクション|スタイリ|fashion|brand|designer|apparel|cloth|wear|knit|denim|vintage|textile/i },
{ name: '🎬 映像', exact: 'movie', 'moviedirector', 'dir', 'documentary', 'anime', 'animation', 'film', 'mv', 'video', 'cg', 'vfx', re: /映像|映画|アニメ|監督|演出|撮影|編集|モーション|エフェクト|movie|film|anime|documentary|director|motion|vfx/i },
{ name: '📎 その他' }
];
const TAG_RE = /(?:^|\s )#([^\s #\\]+)/g;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function groupOf(tag) {
const lc = tag.toLowerCase();
for (let i = 0; i < GROUPS.length - 1; i++) {
const g = GROUPSi;
if ((g.exact && g.exact.indexOf(lc) >= 0) || (g.re && g.re.test(tag))) return i;
}
return GROUPS.length - 1;
}
// ===== タグ集計 =====
const cacheKey = () => 'tagswitch:' + scrapbox.Project.name;
async function countByExport(project) {
const res = await fetch('/api/page-data/export/' + project + '.json');
if (!res.ok) throw new Error('export ' + res.status);
const data = await res.json();
const counts = new Map();
for (const page of data.pages || []) {
const seen = new Set();
for (const raw of page.lines || []) {
const text = typeof raw === 'string' ? raw : (raw && raw.text) || '';
for (const m of text.matchAll(TAG_RE)) seen.add(m1);
}
for (const t of seen) counts.set(t, (counts.get(t) || 0) + 1);
}
if (counts.size === 0) throw new Error('no tags in export');
return counts;
}
async function countByLinks(project) {
const counts = new Map();
let followingId = '';
for (let i = 0; i < 30; i++) {
const url = '/api/pages/' + project + '/search/titles' + (followingId ? '?followingId=' + followingId : '');
const res = await fetch(url);
if (!res.ok) break;
const pages = await res.json();
for (const p of pages) for (const l of p.links || []) counts.set(l, (counts.get(l) || 0) + 1);
followingId = res.headers.get('X-Following-Id') || '';
if (!followingId) break;
}
return counts;
}
async function loadTags(force) {
if (!force) {
try {
const cached = JSON.parse(localStorage.getItem(cacheKey()) || 'null');
if (cached && Date.now() - cached.ts < CACHE_MS) return cached.tags;
} catch (e) { /* キャッシュ破損は無視 */ }
}
let counts;
try {
counts = await countByExport(scrapbox.Project.name);
} catch (e) {
counts = await countByLinks(scrapbox.Project.name);
}
const tags = ...counts.entries()
.filter((t, c) => c >= MIN_COUNT && !EXCLUDE.includes(t))
.sort((a, b) => b1 - a1)
.slice(0, MAX_TAGS);
try {
localStorage.setItem(cacheKey(), JSON.stringify({ ts: Date.now(), tags }));
} catch (e) { /* 保存失敗は無視 */ }
return tags;
}
// ===== タグ挿入 =====
function hasTag(tag) {
const lines = scrapbox.Page.lines || [];
const esc = tag.replace(/[.*+?^${}()|\\\]/g, '\\$&');
const re = new RegExp('(^|\\s\\u3000)#' + esc + '($|\\s\\u3000)');
return lines.some((l) => re.test(l.text));
}
async function insertTag(tag) {
const lines = scrapbox.Page.lines;
if (!lines || lines.length === 0) return false;
const last = lineslines.length - 1;
const lastEl = document.getElementById('L' + last.id);
if (!lastEl) return false;
const rect = lastEl.getBoundingClientRect();
const opt = { bubbles: true, cancelable: true, view: window, button: 0, clientX: rect.right - 2, clientY: rect.top + rect.height / 2 };
lastEl.dispatchEvent(new MouseEvent('mousedown', opt));
lastEl.dispatchEvent(new MouseEvent('mouseup', opt));
lastEl.dispatchEvent(new MouseEvent('click', opt));
await sleep(100);
const input = document.getElementById('text-input');
if (!input) return false;
input.focus();
const endKey = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'End', code: 'End' });
Object.defineProperty(endKey, 'keyCode', { get: () => 35 });
Object.defineProperty(endKey, 'which', { get: () => 35 });
input.dispatchEvent(endKey);
await sleep(50);
let text;
if (last.text.trim() === '') text = '#' + tag;
else if (/^(\s*#^\s +)+\s*$/.test(last.text)) text = ' #' + tag;
else text = '\n#' + tag;
input.value = text;
input.dispatchEvent(new InputEvent('input', { bubbles: true }));
setTimeout(() => input.blur(), 150); // スマホのキーボードを閉じる
return true;
}
// ===== UI =====
const style = document.createElement('style');
style.id = 'tagswitch-style-v2';
style.textContent = [
'#tagswitch-fab { position: fixed; right: 14px; bottom: 84px; z-index: 1050; width: 48px; height: 48px; border-radius: 50%; border: none; background: #2d9c67; color: #fff; font-size: 22px; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,.35); display: none; }',
'#tagswitch-panel { position: fixed; right: 10px; bottom: 140px; z-index: 1050; width: min(320px, calc(100vw - 20px)); max-height: 55vh; overflow-y: auto; background: #fff; color: #333; border-radius: 10px; padding: 10px; box-shadow: 0 4px 16px rgba(0,0,0,.35); display: none; }',
'#tagswitch-panel .ts-head { display: flex; gap: 6px; align-items: center; margin-bottom: 8px; }',
'#tagswitch-panel input { flex: 1; min-width: 0; padding: 6px 8px; border: 1px solid #ccc; border-radius: 6px; font-size: 14px; background: #fff; color: #333; }',
'#tagswitch-panel .ts-btn { border: none; background: #eee; border-radius: 6px; padding: 6px 9px; cursor: pointer; font-size: 14px; }',
'#tagswitch-panel .ts-chips { display: flex; flex-wrap: wrap; gap: 6px; }',
'#tagswitch-panel .ts-group { width: 100%; font-size: 12px; color: #666; font-weight: bold; margin: 8px 0 2px; padding-bottom: 2px; border-bottom: 1px solid #e5e5e5; }',
'#tagswitch-panel .ts-group:first-child { margin-top: 0; }',
'#tagswitch-panel .ts-chip { border: none; border-radius: 14px; padding: 7px 11px; background: #f0f0f0; color: #333; font-size: 13px; cursor: pointer; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
'#tagswitch-panel .ts-chip b { font-weight: 600; }',
'#tagswitch-panel .ts-chip small { color: #888; margin-left: 4px; font-size: 11px; }',
'#tagswitch-panel .ts-chip.on { background: #2d9c67; color: #fff; }',
'#tagswitch-panel .ts-chip.on small { color: #d8f0e4; }',
'#tagswitch-panel .ts-msg { color: #888; font-size: 13px; padding: 4px; }'
].join('\n');
document.head.appendChild(style);
const fab = document.createElement('button');
fab.id = 'tagswitch-fab';
fab.textContent = '🏷️';
const panel = document.createElement('div');
panel.id = 'tagswitch-panel';
panel.innerHTML = '<div class="ts-head"><input type="text" id="tagswitch-filter" placeholder="絞り込み"><button class="ts-btn" id="tagswitch-reload" title="タグ一覧を再集計">🔄</button><button class="ts-btn" id="tagswitch-close">✕</button></div><div class="ts-chips" id="tagswitch-chips"></div>';
document.body.appendChild(fab);
document.body.appendChild(panel);
const chipsEl = panel.querySelector('#tagswitch-chips');
const filterEl = panel.querySelector('#tagswitch-filter');
let tagData = [];
let panelOpen = false;
function setMsg(msg) {
chipsEl.textContent = '';
const d = document.createElement('div');
d.className = 'ts-msg';
d.textContent = msg;
chipsEl.appendChild(d);
}
function renderChips() {
const q = (filterEl.value || '').trim().toLowerCase();
chipsEl.textContent = '';
let shown = 0;
for (let gi = 0; gi < GROUPS.length; gi++) {
const items = tagData.filter((t) => groupOf(t) === gi && (!q || t.toLowerCase().includes(q)));
if (items.length === 0) continue;
const head = document.createElement('div');
head.className = 'ts-group';
head.textContent = GROUPSgi.name;
chipsEl.appendChild(head);
for (const tag, count of items) {
const b = document.createElement('button');
b.className = 'ts-chip' + (hasTag(tag) ? ' on' : '');
const name = document.createElement('b');
name.textContent = '#' + tag;
const num = document.createElement('small');
num.textContent = String(count);
b.appendChild(name);
b.appendChild(num);
b.addEventListener('click', async () => {
if (hasTag(tag)) return;
const ok = await insertTag(tag);
if (!ok) console.warn('tagswitch: 挿入に失敗', tag);
setTimeout(renderChips, 400);
});
chipsEl.appendChild(b);
shown++;
}
}
if (shown === 0) setMsg('タグが見つかりません');
}
async function openPanel(force) {
panelOpen = true;
panel.style.display = 'block';
setMsg('タグを集計中…');
try {
tagData = await loadTags(force);
renderChips();
} catch (e) {
setMsg('タグ集計に失敗しました');
console.warn('tagswitch:', e);
}
}
function closePanel() {
panelOpen = false;
panel.style.display = 'none';
}
fab.addEventListener('click', () => { panelOpen ? closePanel() : openPanel(false); });
panel.querySelector('#tagswitch-close').addEventListener('click', closePanel);
panel.querySelector('#tagswitch-reload').addEventListener('click', () => openPanel(true));
filterEl.addEventListener('input', renderChips);
// ページ表示中だけ🏷️ボタンを出す
setInterval(() => {
const onPage = scrapbox.Layout === 'page';
fab.style.display = onPage ? 'block' : 'none';
if (!onPage && panelOpen) closePanel();
}, 800);
})();