// ==UserScript== // @name かんたんリスト取得 // @namespace https://github.com/yubokumin28/kantan-list // @version 0.4.0 // @description ページの一覧を自動で見つけて表にし、スプレッドシートやAIにそのまま渡せる形で取り出します。Tampermonkey のメニュー「この画面の一覧を取る」で開きます。 // @author 遊牧民 // @match *://*/* // @noframes // @icon https://raw.githubusercontent.com/yubokumin28/kantan-list/main/icon128.png // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_addValueChangeListener // @grant GM_removeValueChangeListener // @grant GM_registerMenuCommand // @grant GM_openInTab // @grant GM_setClipboard // @grant GM_xmlhttpRequest // @connect * // @run-at document-idle // @homepageURL https://yubokumin28.github.io/kantan-list/ // @updateURL https://raw.githubusercontent.com/yubokumin28/kantan-list/main/kantan-list.user.js // @downloadURL https://raw.githubusercontent.com/yubokumin28/kantan-list/main/kantan-list.user.js // ==/UserScript== // ⛔ このファイルは tools/build_userscript.py が作る。直接直さない(正本は 2026-10-17/extension/ の中) (function () { 'use strict'; // ---------- fields.js ---------- // 詳細ページの項目を読む部品。ページの中(content.js)と、パネルで取り寄せたHTMLの両方で使う (() => { if (globalThis.__ksFields) return; const clean = (s) => (s || '').replace(/\s+/g, ' ').trim(); const abs = (u, base) => { if (!u) return ''; try { return new URL(u, base).href; } catch { return ''; } }; const bgUrl = (v) => { const m = /url\(["']?([^"')]+)/.exec(v || ''); return m ? m[1] : ''; }; // 覚えた道筋を順に試す(形の道筋 → 何番目の道筋) function find(doc, f) { for (const s of f.selectors || []) { try { const el = doc.querySelector(s); if (el) return el; } catch { /* 書き方の合わない道筋 */ } } return null; } // live = 画面に出ているページ(見えている文字だけを読む)。取り寄せたHTMLは textContent から script/style を除いて読む function value(el, type, base, live) { if (!el) return ''; if (type === 'link') { const a = el.closest('a[href]') || el.querySelector('a[href]'); return a ? abs(a.getAttribute('href'), base) : ''; } if (type === 'image') { const img = el.tagName === 'IMG' ? el : el.querySelector('img'); if (img) { const ss = (img.getAttribute('srcset') || img.getAttribute('data-srcset') || '').split(',')[0].trim().split(/\s+/)[0]; const s = (live && img.currentSrc) || img.getAttribute('src') || img.getAttribute('data-src') || ss; if (s && !s.startsWith('data:')) return abs(s, base); } return abs(bgUrl(el.getAttribute('style')), base); } if (live) return clean(el.innerText || el.textContent); const c = el.cloneNode(true); c.querySelectorAll('script,style,noscript,template').forEach((x) => x.remove()); return clean(c.textContent); } function read(doc, fields, base, live) { const out = {}; for (const f of fields) out[f.id] = value(find(doc, f), f.type, base, live); return out; } globalThis.__ksFields = { read, find, value, clean }; })(); // ---------- shim.js ---------- // Tampermonkey 版の土台: 拡張の content.js と sidepanel.js が手直し少なく動くよう、chrome.* の代役を用意する。 // ・content.js(ページ側)と sidepanel.js(パネル側)は同じタブの中で動くので、拡張の「メッセージ」はただの関数呼び出しにする // ・保存(chrome.storage.local)は GM_getValue / GM_setValue。キー名は拡張と同じ(recipe3: / detail3:) // ・リンク先のページを開いて読む所(拡張の tabs)は、GM_openInTab で開いたタブとの GM_setValue のやり取りにする // ・外のサイトのHTMLの取り寄せ(拡張の fetch)は GM_xmlhttpRequest const KS_SELF = 'self'; const KS_GUIDE_URL = 'https://yubokumin28.github.io/kantan-list/'; const ks = { contentHandlers: [], // content.js の onMessage connectListeners: [], // content.js の onConnect panelListeners: [], // sidepanel.js の onMessage remotes: new Map(), // このタブが開いたリンク先のタブ remoteId: null, // このタブ自身が「リンク先のタブ」として開かれた時の名前 contentBooted: false, seq: 0, }; // ---------- 画面の部品に触ったか(content.js の「クリックで選ぶ」がパネルの操作を拾わないように) ---------- const __ksIsUi = (e) => { const t = e && e.target; return !!(t && t.closest && t.closest('[data-ks-ui]')); }; // ---------- Trusted Types(YouTube など innerHTML を禁止しているページ) ---------- let ksPolicy; function ksNeedPolicy() { if (ksPolicy !== undefined) return ksPolicy; ksPolicy = null; try { document.createElement('div').innerHTML = ''; } catch { try { ksPolicy = window.trustedTypes.createPolicy('kantan-list', { createHTML: (s) => s }); } catch { /* 作れないページ */ } } return ksPolicy; } function __ksTT(s) { const p = ksNeedPolicy(); return p ? p.createHTML(s) : s; } // パネルの中の部品だけ、innerHTML に入れる文字を通す(ページ全体の決まりは変えない) function ksPatchHtml(root) { if (!ksNeedPolicy()) return; const d = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML'); const iah = Element.prototype.insertAdjacentHTML; root.querySelectorAll('*').forEach((el) => { Object.defineProperty(el, 'innerHTML', { configurable: true, get() { return d.get.call(this); }, set(v) { d.set.call(this, __ksTT(v)); } }); el.insertAdjacentHTML = function (p, s) { return iah.call(this, p, __ksTT(s)); }; }); } // ---------- コピー・取り寄せ ---------- function __ksCopy(text) { if (typeof GM_setClipboard === 'function') { GM_setClipboard(text, 'text'); return Promise.resolve(); } return navigator.clipboard.writeText(text); } // fetch の代わり。拡張の fetchDoc が使う所(ok / status / headers.get / arrayBuffer)だけを返す function __ksFetch(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'arraybuffer', timeout: 30000, onload: (r) => { const head = {}; String(r.responseHeaders || '').split(/\r?\n/).forEach((l) => { const i = l.indexOf(':'); if (i > 0) head[l.slice(0, i).trim().toLowerCase()] = l.slice(i + 1).trim(); }); resolve({ ok: r.status >= 200 && r.status < 300, status: r.status, headers: { get: (n) => head[String(n).toLowerCase()] || null }, arrayBuffer: async () => r.response, }); }, onerror: () => reject(new Error('取り寄せできませんでした')), ontimeout: () => reject(new Error('時間切れ')), }); }); } // ---------- 保存(chrome.storage.local の代わり) ---------- const ksClone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v))); const ksStorage = { get: async (k) => { const keys = k == null ? GM_listValues().filter((x) => !x.startsWith('ks_')) : Array.isArray(k) ? k : [k]; const out = {}; for (const x of keys) { const v = GM_getValue(x); if (v !== undefined) out[x] = v; } return out; }, set: async (o) => { for (const x in o) GM_setValue(x, ksClone(o[x])); }, }; // ---------- ページ側(content.js)の chrome ---------- function ksDeliverToPanel(m, tabId) { setTimeout(() => ks.panelListeners.forEach((f) => f(m, { tab: { id: tabId } })), 0); } function ksUp(o) { GM_setValue('ks_up_' + ks.remoteId, { ...o, k: Math.random() }); } function ksPageChrome() { const off = (list) => (f) => { const i = list.indexOf(f); if (i >= 0) list.splice(i, 1); }; return { runtime: { onMessage: { addListener: (f) => ks.contentHandlers.push(f), removeListener: off(ks.contentHandlers) }, onConnect: { addListener: (f) => ks.connectListeners.push(f), removeListener: off(ks.connectListeners) }, sendMessage: (m) => { if (ks.remoteId) { if (m && m.type === 'field') ksFieldBarCount(1); ksUp({ m }); } else ksDeliverToPanel(m, KS_SELF); }, }, }; } function ksEnsureContent() { if (ks.contentBooted) return; ks.contentBooted = true; __ksBootContent(ksPageChrome()); } function ksCallContent(msg) { return new Promise((resolve, reject) => { const h = ks.contentHandlers[ks.contentHandlers.length - 1]; if (!h) return reject(new Error('not injected')); let done = false; const ret = h(msg, {}, (r) => { if (!done) { done = true; resolve(r); } }); if (ret !== true && !done) resolve(undefined); }); } // ---------- リンク先のタブ(パネル側から見た時) ---------- function ksOpenRemote(url, active) { const id = 'r' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); const r = { id, url, status: 'loading', closed: false, handle: null, waits: new Map(), lsn: null }; ks.remotes.set(id, r); r.lsn = GM_addValueChangeListener('ks_up_' + id, (_n, _o, v) => { if (!v || r.closed) return; if (v.hello) { r.status = 'complete'; r.url = v.url || r.url; return; } if (v.r) { const w = r.waits.get(v.n); if (w) { r.waits.delete(v.n); w(v.res); } return; } if (v.m) { ksDeliverToPanel(v.m, id); // 「選び終わった」で項目選びが済んだら、そのタブを閉じて元のタブ(パネルのある方)に戻す if (v.m.type === 'picked' && v.m.mode === 'field') setTimeout(() => ksCloseRemote(id), 500); } }); GM_setValue('ks_pending', { id, url, t: Date.now() }); r.handle = GM_openInTab(url, { active: !!active, insert: true, setParent: true }); if (r.handle) r.handle.onclose = () => { r.closed = true; }; return { id }; } function ksNavRemote(r, url) { r.status = 'loading'; r.url = url; GM_setValue('ks_pending', { id: r.id, url, t: Date.now() }); GM_setValue('ks_cmd_' + r.id, { n: ++ks.seq, msg: { type: '__nav', url } }); } function ksCloseRemote(id) { const r = ks.remotes.get(id); if (!r || r.closed) return; r.closed = true; try { if (r.handle && r.handle.close) r.handle.close(); else GM_setValue('ks_cmd_' + id, { n: ++ks.seq, msg: { type: '__close' } }); } catch { /* もう閉じている */ } try { if (r.lsn != null && typeof GM_removeValueChangeListener === 'function') GM_removeValueChangeListener(r.lsn); } catch { /* 古い Tampermonkey */ } GM_deleteValue('ks_cmd_' + id); GM_deleteValue('ks_up_' + id); } function ksRemoteCall(id, msg) { const r = ks.remotes.get(id); if (!r || r.closed) return Promise.reject(new Error('no tab')); if (r.status !== 'complete') return Promise.reject(new Error('not ready')); const n = ++ks.seq; return new Promise((ok, ng) => { const to = setTimeout(() => { r.waits.delete(n); ng(new Error('timeout')); }, 15000); r.waits.set(n, (res) => { clearTimeout(to); ok(res); }); GM_setValue('ks_cmd_' + id, { n, msg }); }); } // ---------- このタブが「リンク先のタブ」として開かれたか ---------- // 開く前に ks_pending に {id, url} を置いておき、開いたタブがそれを受け取る(30秒以内・同じサイト。転送で別サイトに変わった時は10秒以内) function ksClaimRemote() { const p = GM_getValue('ks_pending'); if (!p || Date.now() - p.t > 30000) return false; const host = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return ''; } }; if (host(p.url) !== host(location.href) && Date.now() - p.t > 10000) return false; GM_deleteValue('ks_pending'); ks.remoteId = p.id; ksEnsureContent(); GM_addValueChangeListener('ks_cmd_' + p.id, async (_n, _o, v) => { if (!v || !v.msg) return; const m = v.msg; if (m.type === '__nav') { location.href = m.url; return; } if (m.type === '__close') { window.close(); return; } if (m.type === 'pick' && m.mode === 'field') ksFieldBar(true); if (m.type === 'stopPick' || m.type === 'clear') ksFieldBar(false); let res; try { res = await ksCallContent(m); } catch (e) { res = { ok: false, error: String(e) }; } ksUp({ n: v.n, r: 1, res }); }); const hello = () => ksUp({ hello: 1, url: location.href }); if (document.readyState === 'complete') hello(); else { window.addEventListener('load', hello, { once: true }); setTimeout(hello, 4000); } return true; } // リンク先のタブで項目を選んでいる間だけ、上に案内と「選び終わった」を出す(パネルは元のタブにあるため) let ksBar = null, ksBarN = 0; function ksFieldBarCount(d) { ksBarN += d; if (ksBar) ksBar.count.textContent = ksBarN ? `(${ksBarN}個 選びました)` : ''; } function ksFieldBar(show) { if (!show) { if (ksBar) { ksBar.host.remove(); ksBar = null; } return; } if (ksBar) return; ksBarN = 0; const host = document.createElement('kantan-list-bar'); host.setAttribute('data-ks-ui', ''); host.style.cssText = 'position:fixed!important;top:10px!important;left:50%!important;transform:translateX(-50%)!important;z-index:2147483647!important;display:block!important'; const sr = host.attachShadow({ mode: 'open' }); const st = document.createElement('style'); st.textContent = ':host{all:initial}.b{display:flex;gap:10px;align-items:center;font:bold 14px/1.4 "Yu Gothic UI","Meiryo UI",sans-serif;' + 'background:#169a5c;color:#fff;padding:10px 14px;border-radius:14px;box-shadow:0 6px 20px rgba(0,0,0,.3)}' + 'button{font:inherit;cursor:pointer;border:0;border-radius:10px;padding:7px 14px;background:#fff;color:#169a5c}small{font-weight:400;opacity:.9}'; const b = document.createElement('div'); b.className = 'b'; const t = document.createElement('span'); t.textContent = 'かんたんリスト取得: 取りたい所(題名・値段など)を順に押してください '; const count = document.createElement('small'); const done = document.createElement('button'); done.textContent = '選び終わった'; done.onclick = (e) => { e.preventDefault(); e.stopPropagation(); ksUp({ m: { type: 'picked', mode: 'field' } }); ksFieldBar(false); }; t.appendChild(count); b.append(t, done); sr.append(st, b); document.documentElement.appendChild(host); ksBar = { host, count }; } // ---------- パネル側(sidepanel.js)の chrome ---------- function ksSelfInfo() { return { id: KS_SELF, windowId: 1, url: location.href, title: document.title, status: document.readyState === 'complete' ? 'complete' : 'loading' }; } function ksPanelChrome() { return { tabs: { get: async (id) => { if (id === KS_SELF) return ksSelfInfo(); const r = ks.remotes.get(id); if (!r || r.closed) throw new Error('no tab'); return { id, windowId: 1, url: r.url, title: '', status: r.status }; }, query: async () => [ksSelfInfo()], sendMessage: (id, msg) => (id === KS_SELF ? ksCallContent(msg) : ksRemoteCall(id, msg)), connect: (id) => { const pageSide = []; if (id === KS_SELF) ks.connectListeners.forEach((f) => f({ name: 'ks', onDisconnect: { addListener: (g) => pageSide.push(g) } })); return { disconnect: () => pageSide.forEach((g) => g()), onDisconnect: { addListener() {} } }; }, create: async ({ url, active }) => ksOpenRemote(url, active), update: async (id, { url }) => { if (id === KS_SELF) return { id }; const r = ks.remotes.get(id); if (!r || r.closed) throw new Error('no tab'); if (url) ksNavRemote(r, url); return { id }; }, remove: async (id) => ksCloseRemote(id), onActivated: { addListener() {} }, onUpdated: { addListener() {} }, }, scripting: { executeScript: async () => ksEnsureContent() }, storage: { local: ksStorage }, runtime: { onMessage: { addListener: (f) => ks.panelListeners.push(f) }, getURL: (p) => p }, windows: { getCurrent: async () => { throw new Error('窓はありません'); } }, }; } // ---------- content.js(メニューを押した時に動かす) ---------- function __ksBootContent(chrome) { // ページ側の処理: 一覧の検出・項目の取り出し・クリック選択・読み込み操作・詳細ページの項目選び (() => { // 版が同じ時だけ入れ直さない。拡張を更新したら、ページを読み込み直さなくても新しい版に入れ替わる const VERSION = '0.6.4'; if (window.__ksScraper && window.__ksScraper.v === VERSION) return; // 拡張版と Tampermonkey 版を両方入れた時は、先にこのページで動いた方だけが働く(html の data-ks-running)。 // ME は Tampermonkey 版では tools/build_userscript.py が 'userscript' に置き換える const ME = 'userscript'; const root = document.documentElement; if (root.dataset.ksRunning && root.dataset.ksRunning !== ME) { chrome.runtime.onMessage.addListener((_m, _s, reply) => { reply({ ok: false, error: 'このページでは別の「かんたんリスト取得」(拡張版か Tampermonkey 版)が先に動いています。どちらか一方だけを使ってください(ページを読み込み直すと選び直せます)' }); }); return; } root.dataset.ksRunning = ME; if (window.__ksScraper && window.__ksScraper.retire) window.__ksScraper.retire(); const S = (window.__ksScraper = { v: VERSION, candidates: [], current: null, picker: null, retired: false }); const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'SVG', 'TEMPLATE', 'IFRAME', 'HEAD', 'META', 'LINK', 'BR', 'HR', 'CANVAS']); const okClass = (c) => /^[A-Za-z_-][\w-]*$/.test(c) && !/\d{4,}/.test(c) && !c.startsWith('__ks'); const classesOf = (el) => [...el.classList].filter(okClass); const clean = (s) => (s || '').replace(/\s+/g, ' ').trim(); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // ---------- 目印 ---------- const style = document.createElement('style'); style.textContent = ` .__ks-hl{outline:2px solid #16a34a !important;outline-offset:-1px;background:rgba(22,163,74,.06) !important} .__ks-hover{outline:2px dashed #f59e0b !important;outline-offset:-1px;cursor:crosshair !important} .__ks-col{outline:3px solid #2563eb !important;outline-offset:1px;background:rgba(37,99,235,.12) !important} .__ks-pick{outline:3px solid #db2777 !important;outline-offset:1px;background:rgba(219,39,119,.10) !important}`; document.documentElement.appendChild(style); function highlight(items) { document.querySelectorAll('.__ks-hl').forEach((e) => e.classList.remove('__ks-hl')); (items || []).forEach((e) => e.classList.add('__ks-hl')); } function unmark(cls) { document.querySelectorAll('.' + cls).forEach((e) => e.classList.remove(cls)); } // ---------- 一意なCSSパス(何番目で書く。ボタンや項目の控え) ---------- function cssPath(el) { const parts = []; for (let e = el; e && e.nodeType === 1 && e !== document.documentElement; e = e.parentElement) { if (e.id && /^[A-Za-z][\w-]*$/.test(e.id) && document.querySelectorAll('#' + CSS.escape(e.id)).length === 1) { parts.unshift('#' + CSS.escape(e.id)); break; } if (e === document.body) { parts.unshift('body'); break; } const tag = e.tagName.toLowerCase(); const same = [...e.parentElement.children].filter((c) => c.tagName === e.tagName); parts.unshift(same.length > 1 ? `${tag}:nth-of-type(${same.indexOf(e) + 1})` : tag); } return parts.join(' > '); } // ---------- 形で書くCSSパス ---------- // 先祖を「何番目」ではなく「形(タグ+クラス)」で書く。 // YouTube は読み込むたびに同じ形の塊を足すので、「1番目の塊」と書くと2つ目以降を拾えず止まっていた // (2026-10-27 実測: 画面に72件あっても19件しか数えなかった) // keep = 自分から数えて何段目までは、同じ形の兄弟がいても「何番目」で区別するか(表の行のマスを混ぜないため) const STATE = /^(is-|has-)|active|selected|hover|focus|open|show|hidden|visible|current|loaded|loading/i; const shapeOf = (e) => e.tagName + '.' + classesOf(e).sort().join('.'); function shapePath(el, keep) { const parts = []; let depth = 0; for (let e = el; e && e.nodeType === 1 && e !== document.documentElement; e = e.parentElement, depth++) { if (e === document.body) { parts.unshift('body'); break; } const tag = e.tagName.toLowerCase(); if (e.id && /^[A-Za-z][\w-]*$/.test(e.id)) { const idSel = '#' + CSS.escape(e.id); if (document.querySelectorAll(idSel).length === 1) { parts.unshift(idSel); break; } parts.unshift(tag + idSel); // YouTube の #contents のように同じ名前が何個もある箱 continue; } const kin = [...e.parentElement.children].filter((c) => c.tagName === e.tagName); const others = kin.filter((c) => c !== e); const cls = classesOf(e).filter((c) => !STATE.test(c)); let sel = null; if (!others.length) sel = tag; // 同じタグの兄弟がいない else { const own = cls.find((x) => !others.some((o) => o.classList.contains(x))); if (own) sel = tag + '.' + CSS.escape(own); // 自分だけのクラスがある else if (depth >= keep && (cls.length || tag.includes('-')) && others.every((o) => shapeOf(o) === shapeOf(e))) { sel = tag + (cls[0] ? '.' + CSS.escape(cls[0]) : ''); // 同じ形の塊が並んでいる → 全部の塊を指す } } parts.unshift(sel || `${tag}:nth-of-type(${kin.indexOf(e) + 1})`); } return parts.join(' > '); } const qsa = (sel) => { try { return [...document.querySelectorAll(sel)]; } catch { return []; } }; // 兄弟の中で、ref と同じ形のもの(タグ+共通クラス)を集める function likeGroup(ref) { const parent = ref.parentElement; const sameTag = [...parent.children].filter((c) => c.tagName === ref.tagName); // 6割以上の兄弟が持つクラスだけを「共通の印」にする const core = classesOf(ref).filter((c) => sameTag.filter((s) => s.classList.contains(c)).length >= sameTag.length * 0.6); const members = sameTag.filter((s) => core.every((c) => s.classList.contains(c))); return { members, sig: ref.tagName.toLowerCase() + core.map((c) => '.' + CSS.escape(c)).join('') }; } // 同じ親の中で、似た形の兄弟をまとめる。 // 親そのものが同じ形で何個も並んでいたら(読み込むたびに塊が増えるサイト)、全部の塊をまとめる function siblingsLike(ref) { const parent = ref.parentElement; if (!parent) return null; const own = likeGroup(ref); const gp = parent.parentElement; // 名前(id)付きの箱は1つしかない入れ物なので、塊としてまとめない。 // 塊の印になるクラスが無い箱(ただの div)もまとめない(リベシティでメッセージ欄と入力欄を混ぜた) if (gp && parent !== document.body && gp !== document.documentElement && !parent.id) { const pg = likeGroup(parent); if (pg.members.length >= 2 && pg.sig.includes('.')) { const sel = shapePath(gp, 1) + ' > ' + pg.sig + ' > ' + own.sig; const members = qsa(sel); // 塊が少なく、1つの塊に何件も入っている時だけまとめる。 // 表の行(行がたくさん・1行に数マス)のマスを1つの一覧に混ぜないため const holders = pg.members.filter((p) => p.querySelector(':scope > ' + own.sig)).length; if (holders >= 2 && members.length > own.members.length && members.length / holders >= holders) { return { members, itemSelector: sel }; } } } // 親から上を形で書く。今の件を全部含む時だけ使う(あとから足される塊の件も拾える)。 // ここでは同じ形の塊が今すでに並んでいても広げない(1件の中の細かい部品を「全部の動画の分」でまとめて、 // 本命より上に出していた。2026-10-27 実測)。広げるのは読み込みで増えなくなった時だけ(widen) // shapeKey = 同じ形の塊を1つと数えるための名前(候補の一覧に同じ形の塊が何個も並ばないように) const shapeKey = shapePath(parent, 1) + ' > ' + own.sig; const sel = shapePath(parent, Infinity) + ' > ' + own.sig; const all = qsa(sel); const set = new Set(all); if (own.members.every((m) => set.has(m))) return { members: all, itemSelector: sel, shapeKey }; return { members: own.members, itemSelector: cssPath(parent) + ' > ' + own.sig, shapeKey }; } // ---------- 一覧の自動検出 ---------- function detect() { const seen = new Set(); const cands = []; for (const parent of document.body.querySelectorAll('*')) { if (SKIP.has(parent.tagName) || parent.children.length < 3) continue; const byTag = {}; for (const c of parent.children) { if (SKIP.has(c.tagName)) continue; (byTag[c.tagName] = byTag[c.tagName] || []).push(c); } for (const group of Object.values(byTag)) { if (group.length < 3) continue; // いちばん多いクラスを持つ子を基準にする const ref = group.reduce((a, b) => (classesOf(b).length > classesOf(a).length ? b : a), group[0]); const res = siblingsLike(ref); if (!res || res.members.length < 3) continue; const key = res.shapeKey || res.itemSelector; if (seen.has(key)) continue; seen.add(key); const visible = res.members.filter((m) => m.getClientRects().length > 0); if (visible.length < 3) continue; const texts = visible.map((m) => clean(m.innerText)); const avg = texts.reduce((a, t) => a + Math.min(t.length, 200), 0) / visible.length; if (avg < 4) continue; const rich = visible.slice(0, 10).reduce((a, m) => a + (m.querySelector('a[href]') || m.matches('a[href]') ? 1 : 0) + (m.querySelector('img') ? 1 : 0), 0) / Math.min(visible.length, 10); const uniq = new Set(texts).size / texts.length; // 1件に項目(文字のかたまり・リンク・画像)がいくつあるか。1つだけなら「文章の行」とみなして下げる const fields = visible.slice(0, 10).reduce((a, m) => a + Math.min(fieldCount(m), 6), 0) / Math.min(visible.length, 10); const shape = fields >= 2 ? 1 : 0.15; const score = visible.length * Math.min(avg, 150) * (1 + rich * 0.5) * uniq * shape; cands.push({ itemSelector: res.itemSelector, members: res.members, score }); } } cands.sort((a, b) => b.score - a.score); // 別の候補の「1件の中」に全部収まっている並び(メッセージの中の行など)は外す const pool = cands.slice(0, 30); // 重くならないよう上位だけで比べる // 自分より点の高い候補の「1件の中」に収まっている時だけ外す(ページ全体の枠の中の本命は外さない)。 // 何件かにまたがっていても、全部が点の高い候補の件の中にあれば、それは「1件の中の部品」の寄せ集め const inside = (d, el) => d.members.some((m) => m !== el && m.contains(el)); const inner = (c, i) => pool.slice(0, i).some((d) => { const sample = c.members.length <= 12 ? c.members : [c.members[0], ...c.members.filter((_, k) => k % Math.ceil(c.members.length / 10) === 0), c.members[c.members.length - 1]]; return sample.every((el) => inside(d, el)); }); S.candidates = pool.filter((c, i) => !inner(c, i)).slice(0, 5); return summary(); } function fieldCount(item) { let n = 0; const walk = (el) => { for (const c of el.children) { if (SKIP.has(c.tagName)) continue; if (c.tagName === 'IMG' || (c.tagName === 'A' && c.href) || hasOwnText(c)) n++; if (n > 6) return; walk(c); } }; if (hasOwnText(item)) n++; walk(item); return n; } // ---------- 1件の中身を項目に分ける ---------- // 何件にも共通するクラスだけを目印に使う(YouTube の content-id-xxx のような1件ごとの名前を除く) let stable = new Set(); function learnStable(items) { const cnt = {}; for (const it of items) { const seen = new Set(); it.querySelectorAll('*').forEach((e) => classesOf(e).forEach((c) => seen.add(c))); seen.forEach((c) => (cnt[c] = (cnt[c] || 0) + 1)); } const need = Math.max(2, items.length * 0.3); stable = new Set(Object.keys(cnt).filter((c) => cnt[c] >= need || items.length < 3)); } // 列の目印は、その部品から「目印のクラスを持つ一番近い先祖」までの道筋だけで書く。 // 1件の根元まで全部書くと、上の方に箱が1段増えただけで別の列になる // (2026-10-27 Google 実測: 8ページで列15本・タイトル空51行 → この書き方+1ページ目の覚え方の引き継ぎで列6本・空0行) function chainKey(item, el) { if (el === item) return '.'; const part = (e) => { const c = classesOf(e).filter((x) => stable.has(x)).sort()[0]; return e.tagName.toLowerCase() + (c ? '.' + c : ''); }; const parts = [part(el)]; for (let e = el.parentElement; e && e !== item; e = e.parentElement) { parts.unshift(part(e)); if (classesOf(e).some((x) => stable.has(x))) break; } return parts.join('>'); } const hasOwnText = (el) => [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim()); // 遅れて読み込む画像は src が空で、data-src や srcset に本当の場所が入っている function imgSrc(img) { const ss = (img.getAttribute('srcset') || img.getAttribute('data-srcset') || '').split(',')[0].trim().split(/\s+/)[0]; const d = img.dataset; return img.currentSrc || img.getAttribute('src') && img.src || d.src || d.original || d.lazySrc || d.thumb || ss || ''; } const absUrl = (u) => { try { return new URL(u, location.href).href; } catch { return ''; } }; // 列にする画像か。アイコン・絵文字・隠れている画像は除く(リベシティはリアクションの選択メニューで1件に70枚以上あった) function isPhoto(img) { const src = imgSrc(img); if (!src || src.startsWith('data:') || /\.svg(\?|$)/i.test(src)) return false; if (!img.getClientRects().length) return !img.complete || img.naturalWidth === 0; // まだ読み込んでいない画像は残す const r = img.getBoundingClientRect(); return !(r.width <= 28 && r.height <= 28); } // CSSの背景に置かれた画像(商品写真を背景で出すサイトがある)。中に部品の無い箱だけ計算した見た目まで見る function bgOf(el) { let v = el.style && el.style.backgroundImage; if (!v && !el.children.length && el.getClientRects().length) v = getComputedStyle(el).backgroundImage; const m = /url\(["']?([^"')]+)/.exec(v || ''); if (!m || m[1].startsWith('data:') || /\.svg(\?|$)/i.test(m[1])) return ''; const r = el.getBoundingClientRect(); if (r.width && r.width <= 28 && r.height <= 28) return ''; return absUrl(m[1]); } const isLine = (c) => (c.tagName === 'P' || c.tagName === 'LI' || c.tagName === 'BLOCKQUOTE' || (c.tagName === 'DIV' && !c.querySelector('div,p,ul,ol,table,img'))) && clean(c.textContent); function isProse(el) { const kids = [...el.children].filter((c) => !SKIP.has(c.tagName) && clean(c.textContent)); const lines = kids.filter(isLine).length; return lines >= 3 && lines >= kids.length * 0.6; } function readItem(item) { const out = {}; const counter = {}; let order = 0; const add = (base, kind, type, value, el) => { if (!value) return; const k0 = base + '@' + kind; counter[k0] = (counter[k0] || 0) + 1; out[k0 + '#' + counter[k0]] = { type, value, el, order: order++, tag: el.tagName, cls: (typeof el.className === 'string' ? el.className : '') + ' ' + (el.id || ''), head: !!el.closest('h1,h2,h3,h4,h5,h6'), }; }; const visit = (el, inText) => { if (SKIP.has(el.tagName)) return; // 隠れているメニュー(「未読にする」「リアクションを見る」など)は読まない if (el !== item && !el.getClientRects().length && getComputedStyle(el).display === 'none') return; const base = chainKey(item, el); if (el.tagName === 'A' && el.href && !el.href.startsWith('javascript:')) add(base, 'href', 'link', el.href, el); if (el.tagName === 'IMG') { if (isPhoto(el)) add(base, 'src', 'image', absUrl(imgSrc(el)), el); } else add(base, 'bg', 'image', bgOf(el), el); let captured = inText; // 文章の行(段落)が3行以上並ぶ箱は、行ごとに列を分けず「本文」1列にまとめる(チャットの1発言で列が345個になった) if (!inText && isProse(el)) { add(base, 'body', 'text', clean(el.innerText || el.textContent), el); captured = true; } else if (!inText && hasOwnText(el)) { let t = clean(el.innerText || el.textContent); // 「…」で省略された題名は title 属性の全文を使う if (/(\.\.\.|…)$/.test(t) && el.title && el.title.length > t.length) t = clean(el.title); add(base, 'text', 'text', t, el); captured = true; } for (const c of el.children) visit(c, captured); }; visit(item, false); return out; } const PRICE = /([¥¥$€£]\s?[\d,.]+)|([\d,.]+\s?[万千]?円)/; const DATE = /(\d{4}[\/.\-年]\d{1,2})|(\d{1,2}月\d{1,2}日)|(\d{1,2}\/\d{1,2})/; const AGO = /\d+\s?(秒|分|時間|日|週間|か月|ヶ月|年)前|\b\d+\s?(second|minute|hour|day|week|month|year)s? ago/i; const VIEWS = /(回視聴|再生|views?\b)/i; function nameColumn(col, values) { if (/@body#/.test(col.key)) return '本文'; if (col.type === 'link') return 'リンク'; if (col.type === 'image') return '画像'; const sample = values.filter(Boolean).slice(0, 20); const ratio = (re) => sample.filter((v) => re.test(v) && v.length < 40).length / (sample.length || 1); if (ratio(PRICE) > 0.6) return '価格'; if (ratio(VIEWS) > 0.6) return '再生数'; if (ratio(DATE) > 0.6 || ratio(AGO) > 0.6) return '日付'; if (ratio(/^\d{1,2}(:\d{2}){1,2}$/) > 0.6) return '時間'; if (ratio(/^#?\d{1,5}$/) > 0.6) return '番号'; if (ratio(/^[\d.,]+\s?[万億千KMB]?$/i) > 0.6) return '数値'; // YouTube は「4.4万」と「回視聴」が別の部品 if (col.head || /title|name|heading/i.test(col.cls)) return 'タイトル'; return 'テキスト'; } // relearn = 一覧を選び直した時。列の目印と、使う列・捨てる列をここで決める。 // 読み込みで増えた件(relearn=false)は、最初に決めた列をそのまま使う // (少ない件数で「全件同じ値」と判断して列が消えたり、途中から列が増えたりしないように) function extract(items, relearn = true) { // 列の目印は一覧ごとに1回だけ決める(読み込むたびに変えると、同じ発言が別の行として重複する) if (relearn) learnStable(items); const rowsRaw = items.map(readItem); const stat = {}; rowsRaw.forEach((r) => { for (const [k, v] of Object.entries(r)) { const s = (stat[k] = stat[k] || { key: k, type: v.type, tag: v.tag, cls: v.cls, head: v.head, n: 0, order: 0 }); s.n++; s.order += v.order; } }); const known = relearn ? new Map() : (S.known || new Map()); const rejected = relearn ? new Set() : (S.rejected || new Set()); const minN = Math.max(1, Math.floor(items.length * 0.2)); let cols = Object.values(stat) .filter((s) => known.has(s.key) || (!rejected.has(s.key) && s.n >= minN)) .map((s) => ({ ...s, order: s.order / s.n })) .sort((a, b) => a.order - b.order); const fresh = (c) => !known.has(c.key); // 全件同じ「詳しく見る」などのボタン文字は捨てる(同じチャンネル名などの中身は残す) const BUTTONISH = /^(詳しく|詳細|もっと|続き|見る|購入|カート|more|read|view|details?|buy|click|open)/i; cols = cols.filter((c) => { if (!fresh(c) || items.length < 3) return true; const vals = new Set(rowsRaw.map((r) => (r[c.key] ? r[c.key].value : ''))); if (vals.size > 1) return true; const v = [...vals][0]; return !(v === '' || c.type === 'image' || (c.type === 'text' && (v.length <= 2 || BUTTONISH.test(v)))); }); // 前の列と全件同じ値の列も捨てる(画像リンクと題名リンクが同じURLなど) const sig = (c) => rowsRaw.map((r) => (r[c.key] ? r[c.key].value : '')).join('\u0001'); const sigs = new Set(); cols = cols.filter((c) => { const s = sig(c); if (fresh(c) && sigs.has(s)) return false; sigs.add(s); return true; }); // 名前を付ける const used = {}; [...known.values()].forEach((n) => (used[n.replace(/ \(\d+\)$/, '')] = (used[n.replace(/ \(\d+\)$/, '')] || 0) + 1)); cols.filter(fresh).forEach((c) => (c.base = nameColumn(c, rowsRaw.map((r) => (r[c.key] ? r[c.key].value : ''))))); if (relearn && !cols.some((c) => c.base === 'タイトル')) { // 見出しが無ければ、いちばん長い文字の列を題名とみなす const avgLen = (c) => rowsRaw.reduce((a, r) => a + (r[c.key] ? r[c.key].value.length : 0), 0) / rowsRaw.length; const longest = cols.filter((c) => c.base === 'テキスト').sort((a, b) => avgLen(b) - avgLen(a))[0]; if (longest) longest.base = 'タイトル'; } cols.forEach((c) => { if (!fresh(c)) { c.name = known.get(c.key); return; } used[c.base] = (used[c.base] || 0) + 1; c.name = used[c.base] > 1 ? `${c.base} (${used[c.base]})` : c.base; known.set(c.key, c.name); }); if (relearn) Object.keys(stat).forEach((k) => { if (!known.has(k)) rejected.add(k); }); S.known = known; S.rejected = rejected; const rows = rowsRaw.map((r, i) => { // __id = 1件ごとの名札。重複の見分けに使い、表や出力には出さない const o = { __id: rowId(items[i]) }; cols.forEach((c) => (o[c.key] = r[c.key] ? r[c.key].value : '')); return o; }).filter((o) => cols.some((c) => o[c.key])); // 中身が空の行(読み込み中の印など)は出さない return { columns: cols.map((c) => ({ key: c.key, name: c.name, type: c.type })), rows }; } // 名前(id)があればそれ、無ければ中身の文字から作る。 // innerText は画面の再計算が起きて重い(件数が増えるほど1回の読み込みが遅くなっていた)ので textContent で作る。 // 一度付けた名札は、その部品の最初のリンクが変わらない限り使い続ける。 // YouTube は表示の後から「再生中」などの文字が変わり、同じ動画を新しい件として数え直していた(2026-10-27 実測: 画面436件に492件) const ids = new WeakMap(); function rowId(el) { if (el.id) return 'id:' + el.id; const a = el.querySelector('a[href]'); const href = a ? a.href : ''; const got = ids.get(el); if (got && (got.href === href || !got.href)) { got.href = href; return got.id; } const t = clean(el.textContent).slice(0, 400) + '|' + href; let h = 0; for (let i = 0; i < t.length; i++) h = (h * 31 + t.charCodeAt(i)) | 0; const id = 'tx:' + h + ':' + t.length; ids.set(el, { id, href }); return id; } // 一覧を選び直した時は、読んだ印(seenIds)と向き(autoDir)と測った読み込み時間を最初からにする // learned = 「次へ」で移る前のページで決めた列の目印・使う列・捨てる列。移った先でも同じ列に入れるために引き継ぐ // (ページごとに決め直すと、Google は2ページ目から同じ項目が別の列に入った) function useList(itemSelector, members, learned) { if (learned) { stable = new Set(learned.stable); S.known = new Map(learned.known); S.rejected = new Set(learned.rejected); S.stableSel = itemSelector; } const relearn = S.stableSel !== itemSelector || !S.known; S.stableSel = itemSelector; S.current = { itemSelector, members }; S.autoDir = null; S.slowest = 0; highlight(members); const res = extract(members, relearn); S.seenIds = new Set(members.map(rowId)); const keep = { stable: [...stable], known: [...S.known], rejected: [...S.rejected] }; return { ok: true, itemSelector, count: members.length, learned: keep, ...res }; } // 覚えた場所がこのページで0件の時、同じ形の件(道筋の最後の段)が3件以上並ぶ所を探し直す。 // Google の検索結果は1ページ目だけ結果を箱で包み、2ページ目からは包まない。 // 1ページ目で覚えた場所が2ページ目で0件になり、「次へ」で1ページ目しか取れなかった(2026-10-27 実測) function relocate(itemSelector) { const last = itemSelector.split(' > ').pop(); const loose = last.replace(/:nth-of-type\(\d+\)/g, '').replace(/^([\w-]+\.[^.]+)\..*$/, '$1'); // タグ+最初のクラスだけ for (const sel of [...new Set([last, loose])]) { const byParent = new Map(); for (const el of qsa(sel)) { if (el.parentElement) byParent.set(el.parentElement, (byParent.get(el.parentElement) || 0) + 1); } let best = null, n = 0; for (const [p, k] of byParent) if (k > n) { best = p; n = k; } if (!best || n < 3) continue; const res = siblingsLike([...best.children].find((c) => c.matches(sel))); if (res && res.members.length >= 3) return res; } return null; } // まだ読んでいない件だけを読む(毎回60件を読み直すと1回0.5秒かかっていた) function newRows() { const members = qsa(S.current.itemSelector); S.current.members = members; const fresh = []; for (const m of members) { const id = rowId(m); if (S.seenIds.has(id)) continue; S.seenIds.add(id); fresh.push(m); } fresh.forEach((m) => m.classList.add('__ks-hl')); const base = { ok: true, count: members.length, hidden: document.hidden }; if (!fresh.length) return { ...base, columns: [], rows: [] }; return { ...base, ...extract(fresh, false) }; } // 同じ形の塊が後ろに並んでいたら、一覧をその塊まで広げる(一覧を選ぶ前に下まで読んでいた YouTube など)。 // 広げるのは、増えた塊にもそれぞれ5件以上並んでいる時だけ(1件の中の部品を寄せ集めないため) function widen() { const cur = qsa(S.current.itemSelector); if (!cur.length) return false; const last = cur[cur.length - 1]; const sel = shapePath(last.parentElement, 1) + ' > ' + likeGroup(last).sig; if (sel === S.current.itemSelector) return false; const all = qsa(sel); const set = new Set(all); if (all.length <= cur.length || !cur.every((m) => set.has(m))) return false; const holders = new Set(all.map((m) => m.parentElement)).size; if (all.length / holders < 5) return false; S.current.itemSelector = S.stableSel = sel; return true; } // スクロールして、新しく出てきた件だけを返す。 // 'auto' は最初の1回だけ下→上の順に試して、続きのある向きに決める(古い順から始めても新しい順から始めても最後まで行ける) async function step(dir) { if (!S.current) return { ok: false, error: '一覧がまだ選ばれていません' }; if (widen()) return { ...newRows(), grew: true, dir: S.autoDir || dir, itemSelector: S.current.itemSelector }; let tries = [dir]; if (dir === 'auto') { if (S.autoDir) tries = [S.autoDir]; else { // いま一番下にいる(チャットの最新を見ている)なら上から試す。8秒のムダ待ちを避ける const m = qsa(S.current.itemSelector); const box = m.length ? scrollerOf(m[Math.floor(m.length / 2)]) : null; // 今どのあたりにいるか(0=一番上、1=一番下)。下半分なら上から試す const pos = box ? box.scrollTop / Math.max(1, box.scrollHeight - box.clientHeight) : window.scrollY / Math.max(1, document.documentElement.scrollHeight - innerHeight); tries = pos > 0.5 ? ['up', 'down'] : ['down', 'up']; } } let sawBusy = false; for (const d of tries) { let r = await scrollLoad(d); // 増えなかったら、大きく戻してからもう一度端へ(1回の遅れで終わりにしない) if (!r.grew && widen()) return { ...newRows(), grew: true, dir: d, itemSelector: S.current.itemSelector }; if (!r.grew) { sawBusy = sawBusy || r.busy; r = await scrollLoad(d, true); } if (r.grew) { if (dir === 'auto') S.autoDir = d; return { ...newRows(), grew: true, dir: d }; } sawBusy = sawBusy || r.busy; // 大きく戻してやり直しても、いつもの読み込み中の印が出なかった → ここが最後 if (r.gone && (S.autoDir || dir !== 'auto')) return { ...newRows(), grew: false, done: true, dir: d }; } // 向きがまだ決まっていない最初の1回で、上下どちらも増えなかった時だけ「読み込む続きが無い」とする。 // 向きが決まった後は、終わりの判断を窓側(続けて増えない回数)に任せる。 // quiet = 読み込み中の印も見えなかった(窓側は早めに終わりと判断してよい) const firstTry = (dir === 'auto' && !S.autoDir); return { ...newRows(), grew: false, done: firstTry, quiet: !sawBusy, dir: dir === 'auto' ? S.autoDir : dir }; } function current() { if (!S.current) return { ok: false, error: '一覧がまだ選ばれていません' }; // 読み込みで増えた分も拾うため、毎回選び直す return useList(S.current.itemSelector, qsa(S.current.itemSelector)); } // ---------- 候補の一覧を画面に色枠で出して選ばせる ---------- const COLORS = ['#2563eb', '#db2777', '#ea580c', '#16a34a', '#7c3aed', '#0891b2', '#ca8a04', '#dc2626']; const summary = () => S.candidates.map((c, i) => ({ index: i, count: c.members.length, color: COLORS[i % COLORS.length], preview: clean((c.members.find((m) => clean(m.innerText)) || c.members[0]).innerText).slice(0, 40), })); function boxOf(members) { let t = Infinity, l = Infinity, b = -Infinity, r = -Infinity; for (const m of members) { const rc = m.getBoundingClientRect(); if (!rc.width && !rc.height) continue; t = Math.min(t, rc.top); l = Math.min(l, rc.left); b = Math.max(b, rc.bottom); r = Math.max(r, rc.right); } if (t === Infinity) return null; return { top: t + scrollY, left: l + scrollX, width: r - l, height: b - t }; } function choose(index) { hideChooser(); const c = S.candidates[index]; if (!c) return; chrome.runtime.sendMessage({ type: 'picked', mode: 'list', index, data: useList(c.itemSelector, c.members) }); } function chooserKey(e) { if (e.key !== 'Escape') return; hideChooser(); highlight(S.current ? S.current.members : []); chrome.runtime.sendMessage({ type: 'picked', mode: 'list', cancelled: true }); } function showChooser() { hideChooser(); stopPicker(); detect(); const layer = document.createElement('div'); layer.id = '__ks-layer'; layer.style.cssText = 'position:absolute;left:0;top:0;width:0;height:0;z-index:2147483646'; const boxes = S.candidates.map((c, i) => ({ c, i, box: boxOf(c.members) })).filter((x) => x.box); // 大きい枠を下、小さい枠を上に重ねる(入れ子でも内側を押せる) boxes.sort((a, b) => b.box.width * b.box.height - a.box.width * a.box.height); boxes.forEach(({ c, i, box }, z) => { const col = COLORS[i % COLORS.length]; const d = document.createElement('div'); d.style.cssText = `position:absolute;top:${box.top}px;left:${box.left}px;width:${box.width}px;height:${box.height}px;` + `border:3px solid ${col};background:${col}14;cursor:pointer;z-index:${z + 1};box-sizing:border-box`; const tag = document.createElement('div'); tag.textContent = `${i + 1}: ${c.members.length}件`; tag.style.cssText = `position:sticky;top:0;display:inline-block;background:${col};color:#fff;font:bold 13px sans-serif;padding:3px 8px;border-radius:0 0 6px 0`; d.appendChild(tag); d.addEventListener('mouseenter', () => { d.style.background = col + '40'; highlight(c.members); }); d.addEventListener('mouseleave', () => { d.style.background = col + '14'; }); d.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); choose(i); }); layer.appendChild(d); }); document.documentElement.appendChild(layer); document.addEventListener('keydown', chooserKey, true); return summary(); } function hideChooser() { const l = document.getElementById('__ks-layer'); if (l) l.remove(); document.removeEventListener('keydown', chooserKey, true); } // ---------- クリックで選ぶ(押す前に範囲を見せる) ---------- // 上へたどり、似た兄弟がある段を「1件」とみなす。3件以上の段を優先する function resolveList(el) { for (const need of [3, 2]) { for (let a = el; a && a !== document.body; a = a.parentElement) { const res = siblingsLike(a); if (res && res.members.length >= need) return res; } } return null; } // mode: 'list' = 一覧の1件 / 'next' = 「次へ」「もっと見る」ボタン / 'field' = 詳細ページの項目(押すたびに1つ足す) function startPicker(mode) { stopPicker(); hideChooser(); let last = null; let peek = []; const over = (e) => { if (__ksIsUi(e)) return; if (last) last.classList.remove('__ks-hover'); peek.forEach((m) => m.classList.remove('__ks-hover')); last = e.target; if (mode === 'list') { const res = resolveList(last); peek = res ? res.members : []; peek.forEach((m) => m.classList.add('__ks-hover')); } else { last.classList.add('__ks-hover'); } }; // 押した時にリンクが開かないよう、押し下げ・押し上げも止める const block = (e) => { if (__ksIsUi(e)) return; e.preventDefault(); e.stopPropagation(); }; const click = (e) => { if (__ksIsUi(e)) return; block(e); const el = e.target; if (mode === 'field') { el.classList.remove('__ks-hover'); el.classList.add('__ks-pick'); chrome.runtime.sendMessage({ type: 'field', field: describeField(el) }); return; // 続けて選べる。終わりはパネルの「選び終わった」か Esc } stopPicker(); if (mode === 'next') { const btn = el.closest('a,button,[role=button],input[type=button],input[type=submit]') || el; chrome.runtime.sendMessage({ type: 'picked', mode, selector: cssPath(btn), label: clean(btn.innerText || btn.value).slice(0, 30) }); return; } const res = resolveList(el); if (res) { chrome.runtime.sendMessage({ type: 'picked', mode, data: useList(res.itemSelector, res.members) }); return; } chrome.runtime.sendMessage({ type: 'picked', mode, data: { ok: false, error: '似た並びが見つかりませんでした。もう少し外側を押してください' } }); }; const key = (e) => { if (e.key === 'Escape') { stopPicker(); chrome.runtime.sendMessage({ type: 'picked', mode, cancelled: true }); } }; document.addEventListener('mouseover', over, true); document.addEventListener('click', click, true); document.addEventListener('mousedown', block, true); document.addEventListener('mouseup', block, true); document.addEventListener('keydown', key, true); S.picker = () => { document.removeEventListener('mouseover', over, true); document.removeEventListener('click', click, true); document.removeEventListener('mousedown', block, true); document.removeEventListener('mouseup', block, true); document.removeEventListener('keydown', key, true); if (last) last.classList.remove('__ks-hover'); peek.forEach((m) => m.classList.remove('__ks-hover')); }; } function stopPicker() { if (S.picker) { S.picker(); S.picker = null; } } // ---------- 詳細ページの項目 ---------- // 押した所を、形の道筋と何番目の道筋の2通りで覚える(ページごとに少し作りが違っても見つかるように) function describeField(el) { const img = el.tagName === 'IMG' ? el : (!clean(el.innerText) && el.querySelector('img')); const type = img ? 'image' : 'text'; const field = { selectors: [...new Set([shapePath(el, Infinity), cssPath(el)])], type, link: !!el.closest('a[href]') }; field.sample = __ksFields.value(el, type, location.href, true); return field; } // ---------- 列がページのどこから来たかを光らせる ---------- function locate(key) { unmark('__ks-col'); if (!key || !S.current) return { ok: true, found: 0 }; const els = []; for (const m of qsa(S.current.itemSelector).slice(0, 300)) { const r = readItem(m); if (r[key]) els.push(r[key].el); } els.forEach((e) => e.classList.add('__ks-col')); const first = els.find((e) => e.getClientRects().length); if (first) first.scrollIntoView({ block: 'center', behavior: 'smooth' }); return { ok: true, found: els.length }; } // ---------- 読み取り中の印(ページの右下) ---------- function badge(text) { let b = document.getElementById('__ks-badge'); if (!text) { if (b) b.remove(); return; } if (!b) { b = document.createElement('div'); b.id = '__ks-badge'; b.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:2147483647;background:#169a5c;color:#fff;' + 'font:bold 13px sans-serif;padding:8px 14px;border-radius:999px;box-shadow:0 4px 14px rgba(0,0,0,.25);pointer-events:none'; document.documentElement.appendChild(b); } b.textContent = text; } // ---------- 読み込み操作 ---------- // 一覧を包んでいる「スクロールする箱」を探す(リベッターの log_outline など) function scrollerOf(el) { for (let e = el && el.parentElement; e && e !== document.documentElement; e = e.parentElement) { const s = getComputedStyle(e); if (/(auto|scroll)/.test(s.overflowY) && e.scrollHeight > e.clientHeight + 10) return e; } return null; } // 今の一覧の「顔」(件数+最初と最後の中身)。古い分を消しながら読み込むサイト(リベシティのチャット)は件数が増えないので、中身の入れ替わりで判断する function listFace() { const m = S.current ? qsa(S.current.itemSelector) : []; if (!m.length) return '0'; // 「読み込み中」の印のような中身の無い部品は数えない(印が出ただけで変わったと早合点していた) // 半分以上の件に名前(id)があるサイトは、名前の付いた件だけで判断する(リベシティは1.25秒時点で印が1つ増減する) const named = m.filter((e) => e.id); let real = named.length >= m.length / 2 ? named : m.filter((e) => (e.textContent || '').trim().length > 20); // 1件の文字が短い一覧(商品名と値段だけ等)は全部の件で判断する。 // 20文字で切ると「何も無い」とみなして増えても気づかず、毎回いちばん長く待っていた(2026-10-27 実測: 12件に24秒) if (real.length < m.length / 2) real = m.filter((e) => (e.textContent || '').trim()); if (!real.length) return '0'; // 中身の文字ではなく名札で比べる(表示の小さな書き換えを「増えた」と早合点しない) return real.length + '|' + rowId(real[0]) + '|' + rowId(real[real.length - 1]); } // 画面の近くに「読み込み中」の印(くるくる・進み具合の棒)が見えているか const BUSY = '[aria-busy="true"],[role="progressbar"],ytd-continuation-item-renderer,tp-yt-paper-spinner,' + '[class*="spinner" i],[class*="loading" i],[class*="loader" i]'; function busy() { for (const e of document.querySelectorAll(BUSY)) { const r = e.getBoundingClientRect(); if (r.width && r.height && r.bottom > -300 && r.top < innerHeight + 800) return true; } return false; } // 待つ長さは自動。このページで実際にかかった時間から決める(2026-10-27 画面の待ち時間の設定を廃止)。 // YouTube は1回0.4〜1.4秒で届くので、最後の「もう増えない」の判断に毎回8秒も待たない。 // まだ測っていない時は baseWait(8秒)。読み込み中の印が見えている間は maxWait(15秒)まで延ばす(waitChange) function waitLimit() { const max = S.maxWait || 15000; if (!S.slowest) return Math.min(max, S.baseWait || 8000); return Math.min(max, Math.max(2500, S.slowest * 2.5 + 500)); } // 中身が変わるまで待つ。changed=true なら変わった。busy=最後に読み込み中の印が見えていたか。 // gone=いつもすぐ出る読み込み中の印が出ない(続きはもう無い)。 // YouTube は続きがある間、一覧の下に必ず印を出す。最後まで行くと印が消えるので、そこで待つのをやめる // (印を見ずに待つと、終わりの判断だけで15秒かかっていた。2026-10-27 実測) async function waitChange(before) { const t0 = performance.now(); const max = S.maxWait || 15000; let limit = waitLimit(); let sawBusy = false, early = false, tick = 0; // 0.12秒刻みで見張る(0.5秒刻みだと、読み込みが済んでも最大0.5秒ムダに待っていた) for (let t = 0; t < limit; t = performance.now() - t0, tick++) { await sleep(120); const el = performance.now() - t0; if (!early && (el < 1200 || tick % 4 === 0) && busy()) { early = el < 1200; sawBusy = true; } if (listFace() !== before) { S.busyStreak = early ? (S.busyStreak || 0) + 1 : 0; S.slowest = Math.max(S.slowest || 0, performance.now() - t0); // 変わったあと、0.2秒動きが止まるまで待つ(リベシティは「下に足す→上を消す」の2段で変わる) let last = listFace(), quiet = 0; for (let q = 0; q < 2500 && quiet < 200; q += 100) { await sleep(100); const f = listFace(); if (f === last) quiet += 100; else { last = f; quiet = 0; } } return { changed: true, busy: false }; } // 直近3回続けて、読み込みの直後に印が出ていたサイトで、1.5秒たっても印が出ない → 続きは無い if (el > 1500 && !sawBusy && (S.busyStreak || 0) >= 3) return { changed: false, busy: false, gone: true }; // 測った時間を過ぎても、読み込み中の印が見えていれば最大まで待つ if (limit < max && el + 120 >= limit) { if (busy()) { sawBusy = true; limit = max; } } } return { changed: false, busy: sawBusy || busy() }; } // dir: 'down' = 下へ(ふつうのページ・リベッター) / 'up' = 上へ(チャットの古い発言) // strong = 1回目で増えなかった時のやり直し。大きく(1500px)戻してから端へ動かし、読み込みの合図を出し直す async function scrollLoad(dir, strong) { const m = S.current && qsa(S.current.itemSelector); const edge = m && m.length ? (dir === 'up' ? m[0] : m[m.length - 1]) : null; // 端の1件で見つからなければ、真ん中の1件からも探す const box = scrollerOf(edge) || (m && m.length ? scrollerOf(m[Math.floor(m.length / 2)]) : null); const before = listFace(); const back = strong ? 1500 : 600; // 端から少し戻してから端へ動かす(すでに端にいると、読み込みの合図が出ないサイトがある) if (box) { // すでに端にいる時だけ、少し戻してから端へ(端にいない時は戻す0.15秒を省く) const atEdge = strong || (dir === 'up' ? box.scrollTop < 5 : box.scrollTop + box.clientHeight >= box.scrollHeight - 5); if (dir === 'up') { if (atEdge) { box.scrollTop = Math.min(back, box.scrollHeight); await sleep(250); } box.scrollTop = 0; } else { if (atEdge) { box.scrollTop = Math.max(0, box.scrollHeight - box.clientHeight - back); await sleep(250); } box.scrollTop = box.scrollHeight; } } else if (dir === 'up') { window.scrollTo(0, back); await sleep(150); window.scrollTo(0, 0); if (edge && !strong && edge.getBoundingClientRect().top > innerHeight) edge.scrollIntoView({ block: 'start' }); } else { if (strong) { window.scrollTo(0, Math.max(0, document.documentElement.scrollHeight - innerHeight - back)); await sleep(250); } window.scrollTo(0, document.documentElement.scrollHeight); // 端の1件が画面より上に外れた時(下に大きな飾りがあるページ)だけ、端の1件へ戻す。 // いつも戻すと、YouTube の「続きを読む印」(一覧の下)が画面の外に押し出され、1回9.5秒かかっていた(2026-10-27 実測) if (edge && !strong && edge.getBoundingClientRect().bottom < 0) edge.scrollIntoView({ block: 'end' }); } // 決まった秒数ではなく、中身が変わるまで待つ(2.5秒以上かかるサイトがある) const r = await waitChange(before); return { ok: true, grew: r.changed, busy: r.busy, gone: r.gone }; } function findButton(selector, label) { let el = null; try { el = document.querySelector(selector); } catch { /* 書き方の合わない道筋 */ } // 場所が変わったページでは、同じ文字のボタンを探す if (!el && label) el = [...document.querySelectorAll('a,button,[role=button],input[type=button],input[type=submit]')].find((b) => clean(b.innerText || b.value) === label); return el; } function clickNext(selector, label) { const el = findButton(selector, label); if (!el || el.disabled || el.getAttribute('aria-disabled') === 'true' || !el.getClientRects().length) return { clicked: false }; el.click(); return { clicked: true }; } // 同じページで件が増える「もっと見る」ボタン。押して、中身が変わるまで待ち、新しい件だけ返す async function clickMore(selector, label) { if (!S.current) return { ok: false, error: '一覧がまだ選ばれていません' }; const el = findButton(selector, label); if (!el || el.disabled || el.getAttribute('aria-disabled') === 'true' || !el.getClientRects().length) return { ...newRows(), clicked: false }; el.scrollIntoView({ block: 'center' }); const before = listFace(); el.click(); const r = await waitChange(before); return { ...newRows(), clicked: true, grew: r.changed, quiet: !r.busy }; } function peekButton(selector, label) { unmark('__ks-col'); const el = findButton(selector, label); if (!el) return { ok: false }; el.classList.add('__ks-col'); el.scrollIntoView({ block: 'center', behavior: 'smooth' }); setTimeout(() => el.classList.remove('__ks-col'), 2500); return { ok: true }; } // ---------- パネルが閉じたら(つながりが切れたら)枠を消す ---------- const tidy = () => { highlight([]); stopPicker(); hideChooser(); unmark('__ks-col'); unmark('__ks-pick'); badge(''); }; const onConnect = (port) => { if (port.name !== 'ks') return; port.onDisconnect.addListener(tidy); }; if (chrome.runtime.onConnect) chrome.runtime.onConnect.addListener(onConnect); // 新しい版が入ってきたら、この版は返事をやめて身を引く S.retire = () => { S.retired = true; try { chrome.runtime.onMessage.removeListener(onMessage); } catch { /* 古い拡張の文脈 */ } try { if (chrome.runtime.onConnect) chrome.runtime.onConnect.removeListener(onConnect); } catch { /* 同上 */ } tidy(); }; // ---------- サイドパネルとのやり取り ---------- function onMessage(msg, _sender, reply) { if (S.retired) return false; if (msg.maxWait) S.maxWait = msg.maxWait; if (msg.baseWait) S.baseWait = msg.baseWait; if (msg.badge !== undefined) badge(msg.badge); const run = async () => { switch (msg.type) { case 'ping': return { ok: true, url: location.href, title: document.title }; case 'detect': { const list = detect(); if (!list.length) return { ok: false, error: '一覧が見つかりませんでした。「クリックで選ぶ」を使ってください' }; const c = S.candidates[0]; return { candidates: list, index: 0, ...useList(c.itemSelector, c.members) }; } case 'select': { const c = S.candidates[msg.index]; if (!c) return { ok: false, error: '候補がありません' }; return { index: msg.index, ...useList(c.itemSelector, c.members) }; } case 'useSelector': { let sel = msg.itemSelector; let members = qsa(sel); if (!members.length) { const r = relocate(sel); if (r) { sel = r.itemSelector; members = r.members; } } if (!members.length) return { ok: false, error: '保存した一覧がこのページにありません' }; return useList(sel, members, msg.learned); } case 'extract': return current(); case 'pick': startPicker(msg.mode); return { ok: true }; case 'stopPick': stopPicker(); hideChooser(); return { ok: true }; case 'choose': { const list = showChooser(); return list.length ? { ok: true, candidates: list } : { ok: false, error: '並んでいる所が見つかりませんでした。「クリックで選ぶ」を使ってください' }; } case 'peek': { const c = S.candidates[msg.index]; if (c) { highlight(c.members); c.members[0].scrollIntoView({ block: 'center', behavior: 'smooth' }); } return { ok: true }; } case 'locate': return locate(msg.key); case 'peekButton': return peekButton(msg.selector, msg.label); case 'scroll': return scrollLoad(msg.dir === 'up' ? 'up' : 'down'); case 'step': return step(msg.dir || 'auto'); case 'next': return clickNext(msg.selector, msg.label); case 'more': return clickMore(msg.selector, msg.label); case 'readFields': return { ok: true, values: __ksFields.read(document, msg.fields, location.href, true) }; case 'sampleField': { const el = __ksFields.find(document, msg.field); return { ok: true, sample: __ksFields.value(el, msg.field.type, location.href, true) }; } case 'clear': tidy(); return { ok: true }; } return { ok: false, error: 'unknown' }; }; run().then(reply, (e) => reply({ ok: false, error: String(e) })); return true; } chrome.runtime.onMessage.addListener(onMessage); })(); } // ---------- sidepanel.html / sidepanel.css ---------- const KS_PANEL_BODY = "\n
\n \"\"\n
\n
かんたんリスト取得
\n
ページを読み込み中…
\n
\n \n \n
\n\n
\n
\n
\n
\n
\n
\n \n ページ0\n
\n
\n \n 件数0\n
\n
\n \n 時間00:00\n
\n
\n\n
\n \n
\n \n \n
\n
\n
\n
\n\n
\n \n \n
\n \n \n 未設定\n
\n
\n \n
\n
\n \n \n
\n
\n
\n\n
\n \n
\n \n \n \n \n
\n
\n
\n\n
\n \n \n \n
\n \n \n
\n
\n \n
\n
\n
\n \n
\n
\n
\n
\n\n
\n
\n

\n 列名は押すと変えられます。◎ でページのどこから取ったかを光らせ、✕ で列を消せます。設定はサイトごとに自動で覚えます。\n \n \n

\n
\n
"; const KS_PANEL_CSS = ":host{all:initial}\n/* 見た目の正本: 2026-10-18/design/ui_mock.png(ChatGPT で作った見本)。ミントグリーン基調・大きめの角丸・薄い影 */\n:host{\n --bg:#f3fbf7; --card:#ffffff; --soft:#e9f7f0; --line:#d9eee3; --fg:#1f2d27; --mut:#6b7f75;\n --green1:#2ec27e; --green2:#169a5c; --red1:#f26464; --red2:#de3b3b;\n --blue-bg:#e8f2fd; --blue:#1f6fd1; --green-bg:#e3f6ec; --green:#13804b; --orange-bg:#fff3e2; --orange:#e2700b;\n --shadow:0 2px 8px rgba(20,90,60,.08);\n}\n@media (prefers-color-scheme:dark){\n :host{\n --bg:#13201a; --card:#1b2b23; --soft:#20352b; --line:#2c4537; --fg:#e6f3ec; --mut:#9db5a8;\n --blue-bg:#1b2c40; --blue:#7fb6f5; --green-bg:#1c3a2b; --green:#6fdca4; --orange-bg:#3a2a16; --orange:#ffb15e;\n --shadow:none;\n }\n .hero{background:linear-gradient(180deg,#1d3a2c 0%,var(--bg) 100%) !important}\n .stat i{background:rgba(255,255,255,.1) !important}\n .brand{color:#7be0ad !important}\n}\n*{box-sizing:border-box}\n.ksbody{margin:0;font:13px/1.5 \"Yu Gothic UI\",\"Meiryo UI\",\"Segoe UI\",sans-serif;background:var(--bg);color:var(--fg)}\nsvg{width:18px;height:18px;flex:none;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}\nsvg .fill{fill:currentColor;stroke:none}\n\n/* ヘッダー */\n.hero{display:flex;align-items:center;gap:10px;padding:12px 14px;position:relative;overflow:hidden;\n background:linear-gradient(180deg,#e2f6ec 0%,var(--bg) 100%);border-bottom:1px solid var(--line)}\n.avatar{width:48px;height:48px;border-radius:50%;border:3px solid #fff;box-shadow:var(--shadow);flex:none}\n.titles{min-width:0;flex:1}\n.brand{font-weight:800;font-size:18px;color:var(--green2);letter-spacing:.02em}\n.site{color:var(--mut);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n.tent{position:absolute;right:8px;bottom:-2px;width:64px;height:48px;opacity:.9;stroke:none;z-index:0}\nbutton.guide{position:relative;z-index:1;flex:none;padding:4px 10px;font-size:12px;border-radius:999px;color:var(--green2);font-weight:700;margin-right:48px}\n\n/* 読み取り完了の帯 */\n.done{margin:10px 12px 0;padding:10px 12px;border-radius:14px;font-weight:700;font-size:14px;line-height:1.5;\n background:linear-gradient(180deg,#2ec27e,#169a5c);color:#fff;box-shadow:0 4px 14px rgba(22,154,92,.35);animation:pop .35s ease-out}\n.done small{display:block;font-weight:400;font-size:12px;opacity:.9}\n.done.stop{background:linear-gradient(180deg,#f5a524,#d97706);box-shadow:0 4px 14px rgba(217,119,6,.35)}\n@keyframes pop{0%{transform:scale(.92);opacity:0}100%{transform:scale(1);opacity:1}}\n\n/* 数字カード */\n.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;padding:10px 12px}\n.stat{border-radius:14px;padding:8px 10px;display:grid;grid-template-columns:auto 1fr;grid-template-rows:auto auto;column-gap:8px;align-items:center}\n.stat i{grid-row:1/3;width:30px;height:30px;border-radius:50%;display:grid;place-items:center;background:rgba(255,255,255,.7)}\n.stat span{font-size:11px;color:var(--mut)}\n.stat b{font-size:20px;line-height:1.1;font-variant-numeric:tabular-nums}\n.s-blue{background:var(--blue-bg);color:var(--blue)} .s-green{background:var(--green-bg);color:var(--green)} .s-orange{background:var(--orange-bg);color:var(--orange)}\n.stat span{color:inherit;opacity:.75}\n\n/* ボタン */\nbutton{font:inherit;cursor:pointer;border-radius:12px;border:1.5px solid var(--line);background:var(--card);color:var(--fg);\n padding:7px 10px;display:inline-flex;align-items:center;justify-content:center;gap:6px;box-shadow:var(--shadow);transition:filter .15s,transform .05s}\nbutton:hover:not(:disabled){filter:brightness(1.05)}\nbutton:active:not(:disabled){transform:translateY(1px)}\nbutton:disabled{opacity:.45;cursor:default;box-shadow:none}\nbutton.green{background:linear-gradient(180deg,var(--green1),var(--green2));border-color:var(--green2);color:#fff;font-weight:700}\nbutton.red{background:linear-gradient(180deg,var(--red1),var(--red2));border-color:var(--red2);color:#fff;font-weight:700}\nbutton.white{background:var(--card)}\nbutton.big{width:100%;padding:11px 14px;font-size:15px;font-weight:700;border-radius:14px}\nbutton.big.small{width:auto;flex:none;font-size:13px}\nbutton.on{outline:3px solid #f5b400;outline-offset:1px}\n.chev{margin-left:auto;font-size:20px;line-height:1}\n#btnChoose{position:relative;padding-right:34px}\n#btnChoose .chev{position:absolute;right:14px}\n\n.pad{padding:2px 12px 8px;display:flex;flex-direction:column;gap:8px}\n.row{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:8px}\n.row2{display:flex;gap:8px;margin-top:8px}\n.row2 > button{flex:1}\n.row3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px}\n.row3 button,.row4 button{font-size:12px;padding:8px 4px;flex-direction:column;gap:2px}\n.row4{display:grid;grid-template-columns:1fr 1fr;gap:6px}\n.mt{margin-top:6px}\n\n/* 待ち時間・最大件数 */\n.opts{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:8px}\n.opts label{font-size:11px;color:var(--mut);display:flex;flex-direction:column;gap:2px}\ninput[type=number],textarea{width:100%;border:1.5px solid var(--line);background:var(--card);color:var(--fg);border-radius:10px;padding:7px 9px;font:inherit}\ntextarea{resize:vertical}\nlabel.file{display:inline-block;margin-top:4px;font-size:12px;color:var(--green2);cursor:pointer;text-decoration:underline}\nlabel.file input,label.link input{display:none}\n\n/* ページが隠れている時の注意 */\n.warn{margin:10px 12px 0;padding:8px 12px;border-radius:12px;background:var(--orange-bg);color:var(--orange);font-size:12px;font-weight:700}\n\n/* リンク先から取る項目 */\n.fields{display:flex;flex-direction:column;gap:6px;margin-top:8px}\n.fields:empty{display:none}\n.field{display:grid;grid-template-columns:1fr auto auto;gap:4px;align-items:center;background:var(--card);border-radius:10px;padding:6px 8px;border-left:5px solid #db2777}\n.field input{border:0;background:transparent;font:inherit;font-weight:700;color:var(--fg);min-width:0}\n.field select{width:auto;padding:3px 6px;font-size:12px}\n.field .x{border:0;box-shadow:none;padding:0 4px;background:transparent;color:var(--mut)}\n.field .sample{grid-column:1/-1;color:var(--mut);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\nbutton.link,label.link{border:0;box-shadow:none;background:transparent;padding:0 0 0 8px;color:var(--green2);font-size:12px;text-decoration:underline;display:inline;cursor:pointer}\n\n/* 候補カード(左の色帯) */\n.cands{display:flex;flex-direction:column;gap:6px}\n.cand{justify-content:flex-start;text-align:left;padding:7px 10px 7px 14px;border-left:6px solid var(--c);border-radius:12px;position:relative}\n.cand b{color:var(--c);font-size:15px;white-space:nowrap}\n.cand span{color:var(--mut);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;flex:1}\n.cand::after{content:\"›\";color:var(--mut);font-size:18px;margin-left:4px}\n.cand.sel{background:var(--soft);outline:2px solid var(--c)}\n\n/* セクションのカード */\n.card{margin:8px 12px;padding:10px 12px;background:var(--soft);border-radius:14px}\n.lbl{display:flex;align-items:center;gap:6px;font-weight:700;margin-bottom:8px}\n.lbl svg{color:var(--green2)}\nselect{width:100%;border:1.5px solid var(--line);background:var(--card);color:var(--fg);border-radius:10px;padding:8px 10px;font:inherit}\n.hint{color:var(--mut);font-size:12px}\n.hint:empty{display:none}\n#progress{margin-top:6px;color:var(--green)}\n.msg{margin-top:6px}\n.hidden{display:none !important}\n\n/* 表 */\n.tablewrap{margin:8px 12px;border:1px solid var(--line);border-radius:12px;overflow:auto;max-height:calc(100vh - 420px);min-height:140px;background:var(--card)}\ntable{border-collapse:collapse;font-size:12px;min-width:100%}\nth,td{border-bottom:1px solid var(--line);padding:6px 8px;max-width:220px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:left}\nth{background:var(--soft);position:sticky;top:0;z-index:1}\nth input{border:0;background:transparent;font-weight:700;width:110px;padding:0;color:var(--fg);font:inherit;font-weight:700}\nth .x{border:0;padding:0 2px;background:transparent;color:var(--mut);box-shadow:none;display:inline}\nth .x.on{color:#2563eb}\ntd.n{color:var(--mut);text-align:right}\ntd a,td{color:inherit}\n.foot{padding:4px 14px 12px}\n\n/* 別窓(横幅が広い時): 左に操作、右に表 */\n@container ksbody (min-width:640px){\n .layout{flex:1;min-height:0;display:grid;grid-template-columns:340px 1fr}\n .side{overflow-y:auto;border-right:1px solid var(--line);padding-bottom:12px}\n .main{display:flex;flex-direction:column;min-height:0;min-width:0}\n .main .tablewrap{flex:1;max-height:none;margin:12px}\n .stats{grid-template-columns:repeat(3,1fr)}\n .stat{padding:6px 8px}\n .stat b{font-size:17px}\n}\n\n/* Tampermonkey 版の浮かぶ窓の枠。中身の見た目は拡張の sidepanel.css(build で :root→:host などに読み替えて先に入る) */\n.kswin{display:flex;flex-direction:column;background:var(--bg);color:var(--fg);border:1px solid var(--line);border-radius:14px;\n box-shadow:0 12px 40px rgba(0,0,0,.28);overflow:hidden;resize:both;min-width:300px;min-height:200px;max-width:calc(100vw - 8px);max-height:calc(100vh - 8px)}\n.kswin.min{height:auto !important;min-height:0;resize:none}\n.kswin.min .ksbody{display:none}\n.ksbar{display:flex;align-items:center;gap:6px;padding:4px 6px 4px 10px;background:var(--green2);color:#fff;cursor:move;user-select:none;flex:none;\n font:bold 12px/1.4 \"Yu Gothic UI\",\"Meiryo UI\",\"Segoe UI\",sans-serif}\n.ksgrip{opacity:.8}\n.kstitle{flex:1}\n.ksbar button.ksbtn{border:0;box-shadow:none;background:rgba(255,255,255,.18);color:#fff;border-radius:8px;padding:0 8px;height:22px;font-weight:700;cursor:pointer}\n.ksbar button.ksbtn:hover{background:rgba(255,255,255,.32)}\n.ksbody{flex:1;min-height:0;height:100%;display:flex;flex-direction:column;overflow:hidden;container:ksbody/inline-size}\n.ksbody .layout{flex:1;min-height:0;overflow:auto}\n"; // ---------- sidepanel.js + resume.js(パネルを開いた時に動かす) ---------- function __ksBootPanel(chrome, __ksRoot, __ksResumeData) { let __ksTitle = ''; // 拡張では窓の題名に出していた「✅ 完了」など。ページの題名は変えない // サイドパネル: 操作・表示・出力・サイトごとの設定保存・リンク先の巡回 const $ = (id) => __ksRoot.getElementById(id); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const st = { tabId: null, host: '', columns: [], rows: [], seen: new Set(), names: {}, hidden: [], itemSelector: '', nextSelector: '', nextLabel: '', candidates: [], candIndex: 0, pages: 0, t0: 0, timer: null, stop: false, running: false, // リンク先の巡回: fields = 選んだ項目 / tabId = 項目を選ぶ・読むためのタブ / recording = 項目を選んでいる最中 detail: { fields: [], tabId: null, testUrl: '', recording: false, seq: 0 }, }; // 待ち時間は自動(2026-10-27 画面の「いちばん長く待つ」を廃止)。 // 増えたら待たずに次へ進むので、効くのは「増えない時にどこまで待つか」だけ。 // base = まだ測っていない時に待つ長さ / max = 読み込み中の印が見えている時・遅いと測れたサイトで待つ上限。 // 2回目からは、そのページで実際にかかった時間の2.5倍(最低2.5秒)まで縮める(content.js の waitLimit) // gap = ページを移る間隔(サイトに負担をかけない) / workers = リンク先を同時に取り寄せる本数 const WAIT = { base: 8000, max: 15000, gap: 1500, workers: 3 }; // 「次へ」で移った先の待ち。いちばん遅かった回の2.5倍まで(最低4秒。まだ測っていなければ base) const nextWait = () => (st.nextSlowest ? Math.min(WAIT.max, Math.max(4000, st.nextSlowest * 2.5 + 500)) : WAIT.base); const maxItems = () => Math.max(0, parseInt($('maxItems').value, 10) || 0); // ---------- タブとの通信 ---------- // 別窓で開いた時は、開いた時のタブ(?tab=ID)につなぐ。元の窓でタブを切り替えたら、そのタブに付いていく let targetTabId = KS_SELF; async function activeTab() { if (targetTabId) { try { return await chrome.tabs.get(targetTabId); } catch { return null; } } const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); return tab; } async function inject(tabId) { await chrome.scripting.executeScript({ target: { tabId }, files: ['fields.js', 'content.js'] }); } async function sendTo(tabId, msg) { try { return await chrome.tabs.sendMessage(tabId, msg); } catch { await inject(tabId); return chrome.tabs.sendMessage(tabId, msg); } } async function send(msg) { const res = await sendTo(st.tabId, msg); link(); return res; } // ページとつないでおく。パネルを閉じる・別のタブへ移ると切れて、ページ側が枠を消す let port = null; function link() { if (port || !st.tabId) return; try { port = chrome.tabs.connect(st.tabId, { name: 'ks' }); port.onDisconnect.addListener(() => { void chrome.runtime.lastError; port = null; }); } catch { port = null; } } function unlink() { if (!port) return; try { port.disconnect(); } catch { /* もう切れている */ } port = null; } // ---------- 設定の保存(サイトごと) ---------- // v3: 0.6 より前に覚えた一覧の場所は「何番目の塊」で固定されていて YouTube で途中で止まるので、場所だけ捨てる(列名などは引き継ぐ) const recipeKey = (host = st.host) => 'recipe3:' + host; const detailKey = (host) => 'detail3:' + host; async function loadRecipe() { const k = recipeKey(); const r = await chrome.storage.local.get([k, 'recipe2:' + st.host]); if (r[k]) return r[k]; const old = r['recipe2:' + st.host]; return old ? { ...old, itemSelector: '' } : null; } function saveRecipe() { if (!st.host || !st.itemSelector) return; chrome.storage.local.set({ [recipeKey()]: { itemSelector: st.itemSelector, names: st.names, hidden: st.hidden, action: $('action').value, maxItems: $('maxItems').value, nextSelector: st.nextSelector, nextLabel: st.nextLabel, }, }); } // ---------- データの扱い ---------- function resetData() { st.columns = []; st.rows = []; st.seen = new Set(); st.pages = 0; } function merge(res) { // 「次へ」で移った先では、同じ項目でも列の目印(部品の道筋)が変わることがある。 // 初めて見る列は、同じ名前・同じ種類の列に寄せる(Google で2ページ目からの70件が右の別の列に入り、左が空に見えた) // 名前が同じ列を先に、無ければ番号違い(「リンク (2)」と「リンク」)の列を探す。今回の件に中身が来ている列には寄せない const incoming = res.columns || []; const alias = {}; const base = (n) => n.replace(/ \(\d+\)$/, ''); const free = (x, c) => x.type === c.type && !incoming.some((y) => y.key === x.key) && !Object.values(alias).includes(x.key); for (const c of incoming) { if (st.columns.some((x) => x.key === c.key)) continue; const same = st.columns.find((x) => x.name === c.name && free(x, c)) || st.columns.find((x) => base(x.name) === base(c.name) && free(x, c)); if (same) alias[c.key] = same.key; else st.columns.push(c); } let added = 0; const max = maxItems(); for (const r of res.rows || []) { for (const [from, to] of Object.entries(alias)) { r[to] = r[from]; delete r[from]; } if (max && st.rows.length >= max) break; const k = r.__id || JSON.stringify(r); // 1件ごとの名札で重複を見分ける if (st.seen.has(k)) continue; st.seen.add(k); st.rows.push(r); added++; } return added; } const visibleCols = () => st.columns.filter((c) => !st.hidden.includes(c.key)); const colName = (c) => st.names[c.key] || c.name; // ---------- 表示 ---------- function esc(s) { return String(s).replace(/[&<>"]/g, (m) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[m])); } const LIMIT = 5000; let shown = 0, shownSig = '', locating = ''; // 重くならないよう1マスは300字まで const cut = (v) => { const s = String(v || ''); return s.length > 300 ? s.slice(0, 300) + '…' : s; }; const rowHtml = (r, i, cols) => `${i + 1}` + cols.map((c) => { const v = esc(cut(r[c.key])); return `${v}`; }).join('') + ''; // 列が変わった時だけ表を作り直し、ふだんは増えた行だけを足す(毎回全部を描き直すと、件数が増えるほど取得が遅くなっていた) function render(full) { const cols = visibleCols(); const sig = cols.map((c) => c.key + '=' + colName(c)).join('|') + '|' + locating; const tb = $('tbl').tBodies[0]; const upto = Math.min(st.rows.length, LIMIT); if (full || sig !== shownSig || upto < shown) { $('tbl').tHead.innerHTML = '' + cols.map((c) => `` + `` + ``).join('') + ''; tb.innerHTML = st.rows.slice(0, upto).map((r, i) => rowHtml(r, i, cols)).join(''); shownSig = sig; } else if (upto > shown) { tb.insertAdjacentHTML('beforeend', st.rows.slice(shown, upto).map((r, i) => rowHtml(r, shown + i, cols)).join('')); } shown = upto; $('stItems').textContent = st.rows.length.toLocaleString(); $('stPages').textContent = st.pages; const has = st.rows.length > 0; ['btnTsv', 'btnJson', 'btnCsv', 'btnJsonFile'].forEach((id) => ($(id).disabled = !has)); $('btnRun').disabled = !st.itemSelector || st.running; if (st.rows.length > LIMIT) msg(`表示は先頭5,000件まで。出力は全${st.rows.length.toLocaleString()}件です`); fillDetailCols(); } function msg(t) { $('msg').textContent = t || ''; } function warn(t) { $('warn').textContent = t || ''; $('warn').classList.toggle('hidden', !t); } $('tbl').addEventListener('change', (e) => { const k = e.target.dataset.k; if (!k) return; st.names[k] = e.target.value.trim() || undefined; saveRecipe(); }); $('tbl').addEventListener('click', async (e) => { const loc = e.target.dataset.loc; if (loc) { locating = locating === loc ? '' : loc; const r = await send({ type: 'locate', key: locating }); if (locating) msg(r && r.found ? `ページ上の青い枠が「${colName(st.columns.find((c) => c.key === loc) || { name: '' })}」の場所です(${r.found}か所)` : 'この列の場所はページ上に見つかりませんでした(読み込みで消えた件など)'); else msg(''); return render(); } const k = e.target.dataset.x; if (!k) return; st.hidden.push(k); saveRecipe(); render(); }); // ---------- 一覧を選ぶ ---------- function applyList(res, label) { if (!res || res.ok === false) { msg(res ? res.error : '応答がありません'); return false; } st.itemSelector = res.itemSelector; resetData(); merge(res); st.pages = 1; locating = ''; $('candInfo').textContent = label || `${res.count}件の並びを選びました`; render(true); saveRecipe(); return true; } $('btnDetect').onclick = async () => { msg('探しています…'); const res = await send({ type: 'detect' }); st.candidates = res.candidates || []; st.candIndex = 0; st.hidden = []; st.names = {}; renderCands(); if (applyList(res, candLabel())) msg(''); }; function candLabel() { const c = st.candidates[st.candIndex]; return c ? `一覧 ${st.candIndex + 1} を選んでいます(いま画面に見えているのは${c.count}件)` : ''; } // 候補の一覧(パネル側)。押すと切り替え、マウスを乗せるとページ上で光る function renderCands() { $('cands').innerHTML = st.candidates.map((c) => ``).join(''); } $('cands').addEventListener('click', async (e) => { const b = e.target.closest('.cand'); if (!b) return; await send({ type: 'stopPick' }); endPick(); st.candIndex = +b.dataset.i; const res = await send({ type: 'select', index: st.candIndex }); st.hidden = []; st.names = {}; renderCands(); if (applyList(res, candLabel())) msg(''); }); $('cands').addEventListener('mouseover', (e) => { const b = e.target.closest('.cand'); if (b) send({ type: 'peek', index: +b.dataset.i }); }); let pickMode = null; $('btnChoose').onclick = async () => { if (pickMode) { await send({ type: 'stopPick' }); return endPick(); } const res = await send({ type: 'choose' }); if (res.ok === false) return msg(res.error); st.candidates = res.candidates; renderCands(); pickMode = 'choose'; $('btnChoose').classList.add('on'); msg('ページ上の色枠を押すか、下の一覧から選んでください(Escで中止)'); }; $('btnPick').onclick = async () => { if (pickMode) { await send({ type: 'stopPick' }); return endPick(); } pickMode = 'list'; $('btnPick').classList.add('on'); msg('取りたい1件にマウスを乗せると、同じ並びが点線で光ります。押すと決まります(Escで中止)'); await send({ type: 'pick', mode: 'list' }); }; const btnWord = () => ($('action').value === 'more' ? '「もっと見る」' : '「次へ」'); $('btnPickNext').onclick = async () => { pickMode = 'next'; $('btnPickNext').classList.add('on'); msg(`ページの${btnWord()}ボタンを押してください(Escで中止)`); await send({ type: 'pick', mode: 'next' }); }; $('btnPeekNext').onclick = async () => { if (!st.nextSelector) return msg(`先に${btnWord()}ボタンを選んでください`); const r = await send({ type: 'peekButton', selector: st.nextSelector, label: st.nextLabel }); msg(r && r.ok ? 'ページ上で青く光ったボタンを押して進みます' : 'このページでは覚えたボタンが見つかりません。選び直してください'); }; // ページを押すと元の窓が前に来て、この小窓が後ろに隠れる(「消えた」ように見えた。2026-10-27 旦那様の報告)。 // 選び終わったら小窓を前に戻して、結果が見えるようにする async function bringToFront() { try { const w = await chrome.windows.getCurrent(); await chrome.windows.update(w.id, { focused: true }); } catch { /* 試験台など窓の無い所 */ } } function endPick() { pickMode = null; $('btnChoose').classList.remove('on'); $('btnPick').classList.remove('on'); $('btnPickNext').classList.remove('on'); } chrome.runtime.onMessage.addListener((m, sender) => { if (!sender.tab) return; if (m.type === 'field' && sender.tab.id === st.detail.tabId) return addField(m.field); if (m.type === 'picked' && m.mode === 'field' && sender.tab.id === st.detail.tabId) { bringToFront(); return stopRecording(); } if (m.type !== 'picked' || sender.tab.id !== st.tabId) return; endPick(); bringToFront(); if (m.cancelled) return msg('中止しました'); if (m.mode === 'next') { st.nextSelector = m.selector; st.nextLabel = m.label || '(ボタン)'; $('nextLabel').textContent = '設定済み: ' + st.nextLabel; saveRecipe(); return msg(`${btnWord()}ボタンを覚えました`); } st.hidden = []; st.names = {}; if (m.index != null) { st.candIndex = m.index; // 色枠で選んだ renderCands(); if (applyList(m.data, candLabel())) msg(''); } else { st.candidates = []; renderCands(); // クリックで選んだ if (applyList(m.data)) msg(''); } }); // ---------- もっと読み込む ---------- function syncAction() { const a = $('action').value; $('nextRow').classList.toggle('hidden', a !== 'next' && a !== 'more'); $('btnPickNext').textContent = btnWord() + 'ボタンを選ぶ'; } $('action').onchange = () => { syncAction(); saveRecipe(); }; $('maxItems').onchange = saveRecipe; function startTimer() { st.t0 = Date.now(); clearInterval(st.timer); st.timer = setInterval(() => { const s = Math.floor((Date.now() - st.t0) / 1000); $('stTime').textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0'); }, 500); } async function waitTabReady(tabId = st.tabId, ms = 15000) { for (let t = 0; t < ms; t += 250) { const tab = await chrome.tabs.get(tabId); if (tab.status === 'complete') return; await sleep(250); } } // 「次へ」で移った先で、一覧に新しい件が出るまで待つ(ページを読み直さずに中身だけ替わるサイトもある) async function readAfterNext(maxWait) { const t0 = Date.now(); for (;;) { let res; try { res = await send({ type: 'useSelector', itemSelector: st.itemSelector, learned: st.learned }); } catch (e) { res = { ok: false, error: e.message }; } if (res.ok !== false && res.rows.some((r) => !st.seen.has(r.__id))) return res; if (Date.now() - t0 > maxWait) return res; await sleep(400); } } // 取得中はパネルを閉じようとすると確かめる(閉じると止まる) window.addEventListener('beforeunload', (e) => { if (st.running && $('action').value !== 'next') { e.preventDefault(); e.returnValue = ''; } }); function beginRun() { st.running = true; st.stop = false; $('btnStop').classList.remove('hidden'); $('btnStop').disabled = false; $('btnStop').querySelector('.lab').textContent = 'ここで止める'; msg(''); warn(''); $('done').classList.add('hidden'); __ksTitle = 'かんたんリスト取得'; startTimer(); } function endRun(failed) { st.running = false; clearInterval(st.timer); $('btnStop').classList.add('hidden'); $('progress').textContent = ''; $('dProgress').textContent = ''; render(true); if (!failed) showDone(st.stop); } $('btnRun').onclick = async () => { const action = $('action').value; if ((action === 'next' || action === 'more') && !st.nextSelector) return msg(`先に${btnWord()}ボタンを選んでください`); const max = maxItems(); beginRun(); resetData(); st.nextSlowest = 0; render(true); let failed = false; try { let res = await send({ type: 'useSelector', itemSelector: st.itemSelector }); if (res.ok === false) throw new Error(res.error); if (res.itemSelector) st.itemSelector = res.itemSelector; st.learned = res.learned; // 「次へ」で移った先でも、1ページ目で決めた列に入れる merge(res); st.pages = 1; render(); let idle = 0, lastRender = 0; // 回数の上限なし。最後まで行くか(増えなくなる・ボタンが無くなる・最大件数)、「ここで止める」で終わる while (!st.stop && action !== 'none') { if (max && st.rows.length >= max) { msg(`最大件数(${max.toLocaleString()}件)に届いたので終了しました`); break; } $('progress').textContent = `読み込み中… ${st.rows.length.toLocaleString()}件(${st.pages}回目)。止めたい時は「ここで止める」`; const badge = `かんたんリスト取得: ${st.rows.length.toLocaleString()}件 読み取り中`; if (action === 'scroll' || action === 'scrollUp' || action === 'scrollDown') { // スクロール+新しい件だけの読み取りを1回で頼む。自動の時は上下どちらに続きがあるかをページ側で決める const dir = action === 'scrollUp' ? 'up' : action === 'scrollDown' ? 'down' : 'auto'; res = await send({ type: 'step', dir, maxWait: WAIT.max, baseWait: WAIT.base, badge }); // 一覧を広げた時(後ろに同じ形の塊が並んでいた)は、広げた場所を覚え直す if (res.itemSelector && res.itemSelector !== st.itemSelector) { st.itemSelector = res.itemSelector; saveRecipe(); } if (res.ok !== false && res.done) { merge(res); st.pages++; msg(`最後まで取りました(これ以上増えません): ${st.rows.length.toLocaleString()}件`); break; } } else if (action === 'more') { res = await send({ type: 'more', selector: st.nextSelector, label: st.nextLabel, maxWait: WAIT.max, baseWait: WAIT.base, badge }); if (res.ok !== false && !res.clicked) { merge(res); msg('「もっと見る」ボタンが無くなったので終了しました'); break; } } else { const r = await send({ type: 'next', selector: st.nextSelector, label: st.nextLabel, badge }); if (!r.clicked) { msg('「次へ」ボタンが無くなったので終了しました'); break; } await sleep(WAIT.gap); // サイトに負担をかけない間隔 const t0 = Date.now(); await waitTabReady(); res = await readAfterNext(nextWait()); if (res.ok !== false && res.rows.some((r) => !st.seen.has(r.__id))) st.nextSlowest = Math.max(st.nextSlowest, Date.now() - t0); // 移った先で一覧の場所を探し直した時は、続きもその場所で読む(サイトの設定には1ページ目の場所を残す) if (res.itemSelector) st.itemSelector = res.itemSelector; if (res.learned) st.learned = res.learned; } if (res.ok === false) { msg(res.error); break; } if (res.hidden) warn('⚠ 読み取り中のページが隠れています。隠れていると読み込みが止まるサイトがあるので、ページの窓を前に出しておいてください'); const added = merge(res); st.pages++; // 表の描き直しは間引く(増えた行を足すだけでも、毎回だと取得が遅くなる) if (Date.now() - lastRender > 700) { render(); lastRender = Date.now(); } else { $('stItems').textContent = st.rows.length.toLocaleString(); $('stPages').textContent = st.pages; } idle = added === 0 ? idle + 1 : 0; // 3回続けて増えなかったら終わり(1回の読み込みの遅れで止まらないように)。 // 読み込み中の印も見えなかった時と「次へ」は2回で終える const limit = action === 'next' || res.quiet ? 2 : 3; if (idle >= limit) { msg(`最後まで取りました(これ以上増えません): ${st.rows.length.toLocaleString()}件`); break; } } if (st.stop) msg(`ここで止めました: ${st.rows.length.toLocaleString()}件。表の中身はそのまま使えます`); else if (!$('msg').textContent) msg(`完了: ${st.rows.length.toLocaleString()}件`); } catch (e) { failed = true; msg('止まりました: ' + e.message); } try { await send({ type: 'ping', badge: '' }); } catch { /* ページが閉じられた */ } endRun(failed); }; // 読み取りが終わったことを、窓の上の目立つ帯とタイトルで知らせる function showDone(stopped) { const n = st.rows.length.toLocaleString(); const t = $('stTime').textContent; const d = $('done'); d.className = 'done' + (stopped ? ' stop' : ''); d.innerHTML = stopped ? `⏸ 途中で止めました: ${n}件(${t})表の中身はそのままコピー・保存できます` : `✅ 読み取り完了: ${n}件(${t})下の「データを出力」からコピー・保存できます`; __ksTitle = (stopped ? '⏸ 停止 ' : '✅ 完了 ') + n + '件 - かんたんリスト取得'; d.scrollIntoView({ block: 'nearest' }); } $('btnStop').onclick = () => { st.stop = true; $('btnStop').disabled = true; $('btnStop').querySelector('.lab').textContent = '止めています…(数秒)'; }; // ---------- 使い方のページ ---------- $('btnGuide').onclick = () => { GM_openInTab(KS_GUIDE_URL, { active: true }); }; // ---------- 出力 ---------- function table() { const cols = visibleCols(); return { head: cols.map(colName), body: st.rows.map((r) => cols.map((c) => r[c.key] || '')) }; } async function copy(text, label) { await __ksCopy(text); msg(`${label}をコピーしました(${st.rows.length.toLocaleString()}件)`); } // ファイル名に日付と時刻を付ける(同じ日に何回保存しても上書きされない) function fileName(ext) { const d = new Date(); const p = (n) => String(n).padStart(2, '0'); return `${st.host || 'list'}_${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}.${ext}`; } function download(text, type, name) { const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([text], { type })); a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); } const jsonText = () => { const { head, body } = table(); const list = body.map((r) => Object.fromEntries(head.map((h, i) => [h, r[i]]))); return JSON.stringify({ source: st.host, count: list.length, items: list }, null, 2); }; $('btnTsv').onclick = () => { const { head, body } = table(); const cell = (v) => String(v).replace(/[\t\r\n]+/g, ' '); copy([head, ...body].map((r) => r.map(cell).join('\t')).join('\n'), 'スプレッドシート用の表'); }; $('btnJson').onclick = () => copy(jsonText(), 'AI用のJSON'); $('btnCsv').onclick = () => { const { head, body } = table(); const cell = (v) => '"' + String(v).replace(/"/g, '""') + '"'; download('' + [head, ...body].map((r) => r.map(cell).join(',')).join('\r\n'), 'text/csv', fileName('csv')); msg('CSVを保存しました(Excelで文字化けしない形式)'); }; $('btnJsonFile').onclick = () => { download(jsonText(), 'application/json', fileName('json')); msg('JSONを保存しました'); }; // ---------- 設定の書き出し・読み込み(別のPCへ移す・控えを取る) ---------- $('btnExport').onclick = async () => { const all = await chrome.storage.local.get(null); const mine = Object.fromEntries(Object.entries(all).filter(([k]) => /^(recipe3|detail3):/.test(k))); download(JSON.stringify({ app: 'kantan-list', v: 3, items: mine }, null, 2), 'application/json', 'kantan-list-settings.json'); msg(`設定を書き出しました(${Object.keys(mine).length}サイト分)`); }; $('fileImport').onchange = async (e) => { const f = e.target.files[0]; if (!f) return; try { const data = JSON.parse(await f.text()); const items = Object.fromEntries(Object.entries(data.items || {}).filter(([k]) => /^(recipe3|detail3):/.test(k))); await chrome.storage.local.set(items); msg(`設定を読み込みました(${Object.keys(items).length}サイト分)。次にそのサイトを開くと使われます`); } catch { msg('このファイルは読み込めませんでした(書き出した設定ファイルを選んでください)'); } e.target.value = ''; }; // ---------- リンク先のページの中身も取る ---------- const linkCols = () => visibleCols().filter((c) => c.type === 'link'); function fillDetailCols() { const sel = $('dCol'); const cols = linkCols(); const sig = cols.map((c) => c.key + colName(c)).join('|'); if (sel.dataset.sig === sig) return; const keep = sel.value; sel.innerHTML = cols.length ? cols.map((c) => ``).join('') : ''; if (cols.some((c) => c.key === keep)) sel.value = keep; sel.dataset.sig = sig; } $('dSrc').onchange = () => { const paste = $('dSrc').value === 'paste'; $('dCol').classList.toggle('hidden', paste); $('dPasteRow').classList.toggle('hidden', !paste); }; // CSVの中から http で始まる値が一番多い列を URL の列とみなす $('dFile').onchange = async (e) => { const f = e.target.files[0]; if (!f) return; const text = (await f.text()).replace(/^/, ''); const rows = text.split(/\r?\n/).filter(Boolean).map((l) => l.split(/,|\t/).map((v) => v.replace(/^"|"$/g, '').trim())); const width = Math.max(...rows.map((r) => r.length)); let best = 0, bestN = -1; for (let i = 0; i < width; i++) { const n = rows.filter((r) => /^https?:\/\//.test(r[i] || '')).length; if (n > bestN) { best = i; bestN = n; } } $('dUrls').value = rows.map((r) => r[best]).filter((v) => /^https?:\/\//.test(v || '')).join('\n'); msg(`CSVから${bestN}件のURLを読み込みました`); e.target.value = ''; }; // 巡回するURLの一覧。同じURLは1回だけ開く function detailUrls() { let list; if ($('dSrc').value === 'paste') list = $('dUrls').value.split(/\s+/).filter((u) => /^https?:\/\//.test(u)); else { const k = $('dCol').value; list = k ? st.rows.map((r) => r[k]).filter((u) => /^https?:\/\//.test(u || '')) : []; } return [...new Set(list)]; } function renderFields() { const f = st.detail.fields; $('dFields').innerHTML = f.map((x, i) => `
` + `` + `` + `${esc(x.sample || '(空)')}
`).join('') + (f.length ? '' : ''); $('btnRunDetail').disabled = !f.length || st.running || st.detail.recording; } function saveFields(url) { try { chrome.storage.local.set({ [detailKey(new URL(url).hostname)]: { fields: st.detail.fields } }); } catch { /* URLでない */ } } function addField(field) { const n = ++st.detail.seq; const name = field.type === 'image' ? `画像${n}` : `項目${n}`; st.detail.fields.push({ ...field, id: 'f' + Date.now().toString(36) + n, name }); renderFields(); saveFields(st.detail.testUrl); } $('dFields').addEventListener('change', async (e) => { const t = e.target; if (t.dataset.fn) st.detail.fields[+t.dataset.fn].name = t.value.trim() || '項目'; if (t.dataset.ft) { const f = st.detail.fields[+t.dataset.ft]; f.type = t.value; // 種類を変えたら、開いているページで見本を取り直す if (st.detail.tabId) { try { const r = await sendTo(st.detail.tabId, { type: 'sampleField', field: f }); f.sample = r.sample; } catch { /* タブが閉じられた */ } } renderFields(); } saveFields(st.detail.testUrl); }); $('dFields').addEventListener('click', (e) => { if (e.target.dataset.fclear) { st.detail.fields = []; st.detail.seq = 0; if (st.detail.tabId && !st.detail.recording) sendTo(st.detail.tabId, { type: 'clear' }).catch(() => {}); renderFields(); saveFields(st.detail.testUrl); return; } const i = e.target.dataset.fx; if (i == null) return; st.detail.fields.splice(+i, 1); renderFields(); saveFields(st.detail.testUrl); }); async function openDetailTab(url, active) { const tab = await activeTab(); if (st.detail.tabId) { try { await chrome.tabs.update(st.detail.tabId, { url, active }); return st.detail.tabId; } catch { /* 閉じられていた */ } } const t = await chrome.tabs.create({ url, active, windowId: tab ? tab.windowId : undefined }); st.detail.tabId = t.id; return t.id; } function stopRecording() { if (!st.detail.recording) return; st.detail.recording = false; if (st.detail.tabId) sendTo(st.detail.tabId, { type: 'stopPick' }).catch(() => {}); $('btnPickFields').classList.remove('on'); $('btnPickFields').querySelector('.lab').textContent = '取る項目を選ぶ'; renderFields(); msg(st.detail.fields.length ? `${st.detail.fields.length}個の項目を覚えました。「全部のページで取得」で全部のページから取ります` : ''); } $('btnPickFields').onclick = async () => { if (st.detail.recording) return stopRecording(); const urls = detailUrls(); if (!urls.length) return msg('先に、表のリンク列を選ぶか、URLを貼り付けてください'); st.detail.testUrl = urls[0]; // 同じサイトで前に選んだ項目があれば使う const host = new URL(urls[0]).hostname; const saved = (await chrome.storage.local.get(detailKey(host)))[detailKey(host)]; if (saved && saved.fields && !st.detail.fields.length) { st.detail.fields = saved.fields; st.detail.seq = saved.fields.length; } const tabId = await openDetailTab(urls[0], true); await waitTabReady(tabId); await sleep(600); // 表示の仕上がりを待つ await sendTo(tabId, { type: 'pick', mode: 'field' }); st.detail.recording = true; $('btnPickFields').classList.add('on'); $('btnPickFields').querySelector('.lab').textContent = '選び終わった'; renderFields(); msg('開いたページで、取りたい所(題名・値段・説明など)を順に押してください。押すたびに下に項目が増えます。終わったら「選び終わった」'); }; // 文字コードを確かめてから文字にする(Shift_JIS のページを文字化けさせない) async function fetchDoc(url) { const r = await __ksFetch(url); if (!r.ok) throw new Error('HTTP ' + r.status); const buf = await r.arrayBuffer(); let cs = (/charset=([\w-]+)/i.exec(r.headers.get('content-type') || '') || [])[1]; if (!cs) cs = (/]+charset=["']?([\w-]+)/i.exec(new TextDecoder('ascii').decode(buf.slice(0, 4096))) || [])[1]; let html; try { html = new TextDecoder(cs || 'utf-8').decode(buf); } catch { html = new TextDecoder('utf-8').decode(buf); } return new DOMParser().parseFromString(__ksTT(html), 'text/html'); } const near = (a, b) => { a = String(a || ''); b = String(b || ''); if (!b) return true; if (!a) return false; return a === b || a.includes(b.slice(0, 30)) || b.includes(a.slice(0, 30)); }; // 速い道が使えるか: 試しのページを取り寄せて、開いたページで見た見本と同じ値が取れるか async function canFetch(fields, url) { try { const doc = await fetchDoc(url); const v = __ksFields.read(doc, fields, url, false); return fields.every((f) => near(v[f.id], f.sample)); } catch { return false; } } $('btnRunDetail').onclick = async () => { const fields = st.detail.fields; const urls = detailUrls(); if (!fields.length || !urls.length) return; const fromCol = $('dSrc').value === 'col' ? $('dCol').value : ''; const max = maxItems(); const list = max ? urls.slice(0, max) : urls; beginRun(); renderFields(); // 結果の置き場: 表のリンク列から → 同じ行に列を足す / 貼り付けたURL → 新しい表 if (fromCol) { for (const f of fields) { const key = 'd:' + f.id; const c = st.columns.find((x) => x.key === key); if (c) c.name = f.name; else st.columns.push({ key, name: f.name, type: f.type }); } } else { resetData(); st.columns = [{ key: 'url', name: 'URL', type: 'link' }, ...fields.map((f) => ({ key: 'd:' + f.id, name: f.name, type: f.type }))]; st.itemSelector = ''; } const byUrl = new Map(); if (fromCol) st.rows.forEach((r) => { const u = r[fromCol]; if (u) (byUrl.get(u) || byUrl.set(u, []).get(u)).push(r); }); const put = (url, v) => { if (fromCol) (byUrl.get(url) || []).forEach((r) => fields.forEach((f) => (r['d:' + f.id] = v[f.id] || ''))); else { st.rows.push({ __id: url, url, ...Object.fromEntries(fields.map((f) => ['d:' + f.id, v[f.id] || ''])) }); } }; let done = 0, empty = 0, lastRender = 0; const tick = (how) => { $('dProgress').textContent = `${how}: ${done.toLocaleString()} / ${list.length.toLocaleString()}ページ` + (empty ? `(取れなかったページ ${empty})` : ''); st.pages = done; if (Date.now() - lastRender > 800) { render(true); lastRender = Date.now(); } }; try { $('dProgress').textContent = '速く取れるか確かめています…'; const fast = await canFetch(fields, st.detail.testUrl || list[0]); if (fast) { // 速い道: ページを開かずにHTMLだけ取り寄せる。何本か同時に進める let i = 0; const worker = async () => { while (!st.stop && i < list.length) { const url = list[i++]; let v = {}; try { v = __ksFields.read(await fetchDoc(url), fields, url, false); } catch { /* 下で空として数える */ } if (!fields.some((f) => v[f.id])) empty++; put(url, v); done++; tick('ページを開かずに取得中'); await sleep(WAIT.gap / WAIT.workers); } }; await Promise.all(Array.from({ length: Math.min(WAIT.workers, list.length) }, worker)); } else { // ふつうの道: 1つのタブで順に開いて読む(画面を組み立てるサイト向け) for (const url of list) { if (st.stop) break; const tabId = await openDetailTab(url, false); await waitTabReady(tabId, WAIT.max); let v = {}; // 項目が全部見つかるまで読み直す。一部しか無いページは、取れた値が1.2秒変わらなければ次へ // (無い項目を毎ページいちばん長く待つと、4ページに39秒かかった。2026-10-27 実測) const t0 = Date.now(); let lastSig = '', still = 0; for (;;) { try { v = (await sendTo(tabId, { type: 'readFields', fields })).values || {}; } catch { v = {}; } if (fields.every((f) => v[f.id]) || Date.now() - t0 > WAIT.base) break; const sig = JSON.stringify(v); still = sig === lastSig && fields.some((f) => v[f.id]) ? still + 400 : 0; lastSig = sig; if (still >= 1200) break; await sleep(400); } if (!fields.some((f) => v[f.id])) empty++; put(url, v); done++; tick('ページを順に開いて取得中'); await sleep(WAIT.gap); } } msg(st.stop ? `ここで止めました: ${done}ページ分を表に入れました` : `リンク先 ${done.toLocaleString()}ページの中身を表に入れました` + (empty ? `(${empty}ページは項目が見つかりませんでした)` : '')); } catch (e) { msg('止まりました: ' + e.message); } if (st.detail.tabId) { try { await chrome.tabs.remove(st.detail.tabId); } catch { /* もう閉じている */ } st.detail.tabId = null; } if (st.tabId) { try { await chrome.tabs.update(st.tabId, { active: true }); } catch { /* 閉じられた */ } } endRun(false); renderFields(); }; // ---------- タブが切り替わったら付け直す ---------- async function attach() { const tab = await activeTab(); if (!tab || st.running || st.detail.recording) return; if (tab.id === st.tabId && st.itemSelector) return; if (tab.id !== st.tabId) unlink(); // 前のタブの枠を消す st.tabId = tab.id; st.itemSelector = ''; st.candidates = []; st.names = {}; st.hidden = []; st.nextSelector = ''; st.nextLabel = ''; resetData(); $('candInfo').textContent = ''; $('cands').innerHTML = ''; msg(''); warn(''); let url; try { url = new URL(tab.url); } catch { url = null; } if (!url || !/^https?:$/.test(url.protocol)) { $('site').textContent = 'このページでは使えません(普通のWebページを開いてください)'; render(true); return; } st.host = url.hostname; $('site').textContent = (tab.title || '') + ' / ' + st.host; const rec = await loadRecipe(); if (rec) { st.names = rec.names || {}; st.hidden = rec.hidden || []; st.nextSelector = rec.nextSelector || ''; st.nextLabel = rec.nextLabel || ''; $('action').value = rec.action || 'none'; $('maxItems').value = rec.maxItems || ''; $('nextLabel').textContent = st.nextSelector ? '設定済み: ' + st.nextLabel : '未設定'; syncAction(); if (rec.itemSelector) { try { const res = await send({ type: 'useSelector', itemSelector: rec.itemSelector }); if (res.ok !== false) { st.itemSelector = res.itemSelector; merge(res); st.pages = 1; $('candInfo').textContent = `前回の設定で${res.count}件を見つけました`; return render(true); } } catch { /* 下の自動検出へ */ } } } else { $('action').value = 'none'; syncAction(); } render(true); try { const keepNames = st.names, keepHidden = st.hidden; await $('btnDetect').onclick(); if (rec) { st.names = keepNames; st.hidden = keepHidden; render(true); saveRecipe(); } } catch (e) { msg('このページは読み取れませんでした: ' + e.message); } } // 別窓で開いた時は、アイコンを押した時のタブだけを相手にする(タブを切り替えても付いていかない)。 // 別のタブで使う時は、そのタブでアイコンを押し直すと、そのタブにつなぎ直される(background.js) chrome.tabs.onActivated.addListener(() => { if (!targetTabId) attach(); }); chrome.tabs.onUpdated.addListener((id, info) => { if (id === st.tabId && info.status === 'complete' && !st.running) { st.tabId = null; attach(); } }); syncAction(); setTimeout(__ksStart, 0); // ---------- resume.js ---------- // Tampermonkey 版の追加(sidepanel.js と同じ入れ物の中に貼られるので、st・send・merge などをそのまま使える)。 // 拡張はパネルが別の窓なので「次へ」でページが切り替わっても続けられたが、Tampermonkey 版はパネルがページの中にあり、 // ページが切り替わると一緒に消える。そこで「次へ」を押す直前に途中の表を ks_resume に預け、次のページで受け取って続きから取る。 let __ksIdle = 0, __ksLastLen; const __ksSend0 = send; send = async function (m) { if (m && m.type === 'next') { // 続けて増えなかった回数(拡張の idle と同じ数え方。2回で終わり)を、ページをまたいでも数え続ける if (__ksLastLen !== undefined) __ksIdle = st.rows.length === __ksLastLen ? __ksIdle + 1 : 0; __ksLastLen = st.rows.length; GM_setValue('ks_resume', { t: Date.now(), clickAt: Date.now(), host: st.host, fromUrl: location.href, itemSelector: st.itemSelector, learned: st.learned, names: st.names, hidden: st.hidden, nextSelector: st.nextSelector, nextLabel: st.nextLabel, maxItems: $('maxItems').value, columns: st.columns, rows: st.rows, seen: [...st.seen], pages: st.pages, nextSlowest: st.nextSlowest || 0, t0: st.t0, idle: __ksIdle, lastLen: __ksLastLen, candInfo: $('candInfo').textContent, }); } return __ksSend0(m); }; const __ksEnd0 = endRun; endRun = function (failed) { GM_deleteValue('ks_resume'); __ksIdle = 0; __ksLastLen = undefined; return __ksEnd0(failed); }; // 前のページから預かった表を戻して、「次へ」の続きを取る(拡張の btnRun の「次へ」の所と同じ流れ) async function __ksResume(sv) { st.tabId = KS_SELF; st.host = sv.host; Object.assign(st, { itemSelector: sv.itemSelector, learned: sv.learned, names: sv.names || {}, hidden: sv.hidden || [], nextSelector: sv.nextSelector, nextLabel: sv.nextLabel, columns: sv.columns || [], rows: sv.rows || [], seen: new Set(sv.seen || []), pages: sv.pages || 1, nextSlowest: sv.nextSlowest || 0, }); $('site').textContent = (document.title || '') + ' / ' + st.host; $('action').value = 'next'; $('maxItems').value = sv.maxItems || ''; syncAction(); $('nextLabel').textContent = '設定済み: ' + st.nextLabel; $('candInfo').textContent = sv.candInfo || ''; __ksIdle = sv.idle || 0; __ksLastLen = sv.lastLen; beginRun(); st.t0 = sv.t0 || Date.now(); const sec = Math.floor((Date.now() - st.t0) / 1000); $('stTime').textContent = String(Math.floor(sec / 60)).padStart(2, '0') + ':' + String(sec % 60).padStart(2, '0'); render(true); const max = maxItems(); let failed = false, idle = sv.idle || 0, first = true, lastRender = 0; try { while (!st.stop) { let t0 = sv.clickAt || Date.now(); if (!first) { if (max && st.rows.length >= max) { msg(`最大件数(${max.toLocaleString()}件)に届いたので終了しました`); break; } $('progress').textContent = `読み込み中… ${st.rows.length.toLocaleString()}件(${st.pages}回目)。止めたい時は「ここで止める」`; const badge = `かんたんリスト取得: ${st.rows.length.toLocaleString()}件 読み取り中`; const r = await send({ type: 'next', selector: st.nextSelector, label: st.nextLabel, badge }); if (!r.clicked) { msg('「次へ」ボタンが無くなったので終了しました'); break; } await sleep(WAIT.gap); t0 = Date.now(); } first = false; await waitTabReady(); const res = await readAfterNext(nextWait()); if (res.ok !== false && res.rows.some((r) => !st.seen.has(r.__id))) st.nextSlowest = Math.max(st.nextSlowest || 0, Date.now() - t0); if (res.itemSelector) st.itemSelector = res.itemSelector; if (res.learned) st.learned = res.learned; if (res.ok === false) { msg(res.error); break; } if (res.hidden) warn('⚠ 読み取り中のページが隠れています。隠れていると読み込みが止まるサイトがあるので、ページの窓を前に出しておいてください'); const added = merge(res); st.pages++; if (Date.now() - lastRender > 700) { render(); lastRender = Date.now(); } else { $('stItems').textContent = st.rows.length.toLocaleString(); $('stPages').textContent = st.pages; } idle = added === 0 ? idle + 1 : 0; if (idle >= 2) { msg(`最後まで取りました(これ以上増えません): ${st.rows.length.toLocaleString()}件`); break; } } if (st.stop) msg(`ここで止めました: ${st.rows.length.toLocaleString()}件。表の中身はそのまま使えます`); else if (!$('msg').textContent) msg(`完了: ${st.rows.length.toLocaleString()}件`); } catch (e) { failed = true; msg('止まりました: ' + e.message); } try { await send({ type: 'ping', badge: '' }); } catch { /* ページが閉じられた */ } endRun(failed); } function __ksStart() { if (__ksResumeData) __ksResume(__ksResumeData); else attach(); } // パネルの外(boot.js)から使う口 return { isRunning: () => st.running, stop: () => { st.stop = true; }, unlink: () => unlink(), }; } // ---------- boot.js ---------- // Tampermonkey 版の入口。ふだんは何も出さず、Tampermonkey のメニュー「この画面の一覧を取る」を押した時だけパネルを出す。 // パネルはページの中に浮かぶ窓(Shadow DOM の中なので、ページ側の見た目に崩されない)。ドラッグで動かせ、位置と大きさを覚える。 let ksPanel = null; // { host, win, api } function ksBox() { const b = GM_getValue('ks_panel_box') || {}; const w = Math.min(b.w || 760, innerWidth - 16); const h = Math.min(b.h || 720, innerHeight - 16); const x = b.x == null ? innerWidth - w - 16 : b.x; const y = b.y == null ? 16 : b.y; return { w, h, x: Math.max(0, Math.min(x, innerWidth - 120)), y: Math.max(0, Math.min(y, innerHeight - 40)) }; } function ksSaveBox() { if (!ksPanel) return; const r = ksPanel.win.getBoundingClientRect(); const old = GM_getValue('ks_panel_box') || {}; const min = ksPanel.win.classList.contains('min'); GM_setValue('ks_panel_box', { x: Math.round(r.left), y: Math.round(r.top), w: min ? old.w : Math.round(r.width), h: min ? old.h : Math.round(r.height) }); } function ksOpenPanel(resumeData) { const root = document.documentElement; if (root.dataset.ksRunning === 'extension') { alert('このページでは拡張機能版の「かんたんリスト取得」が先に動いています。\nどちらか一方だけを使ってください(ページを読み込み直すと選び直せます)。'); return; } if (ksPanel) { ksPanel.host.style.setProperty('display', 'block', 'important'); return; } ksEnsureContent(); const box = ksBox(); const host = document.createElement('kantan-list-panel'); host.setAttribute('data-ks-ui', ''); host.style.cssText = `position:fixed!important;left:${box.x}px!important;top:${box.y}px!important;z-index:2147483647!important;display:block!important;margin:0!important;padding:0!important`; // パネルの中で打った文字が、ページのショートカット(YouTube の k や / など)に化けないようにする ['keydown', 'keyup', 'keypress'].forEach((t) => host.addEventListener(t, (e) => e.stopPropagation())); const sr = host.attachShadow({ mode: 'open' }); sr.innerHTML = __ksTT(`` + `
` + '
⠿かんたんリスト取得' + '' + '
' + `
${KS_PANEL_BODY}
`); ksPatchHtml(sr); root.appendChild(host); const win = sr.querySelector('.kswin'); ksPanel = { host, win, api: null }; // ドラッグで移動 const bar = sr.querySelector('.ksbar'); bar.addEventListener('mousedown', (e) => { if (e.button !== 0 || e.target.closest('button')) return; e.preventDefault(); const sx = e.clientX, sy = e.clientY; const r = host.getBoundingClientRect(); const move = (ev) => { const x = Math.max(0, Math.min(r.left + ev.clientX - sx, innerWidth - 120)); const y = Math.max(0, Math.min(r.top + ev.clientY - sy, innerHeight - 30)); host.style.setProperty('left', x + 'px', 'important'); host.style.setProperty('top', y + 'px', 'important'); }; const up = () => { window.removeEventListener('mousemove', move, true); window.removeEventListener('mouseup', up, true); ksSaveBox(); }; window.addEventListener('mousemove', move, true); window.addEventListener('mouseup', up, true); }); // 右下の角で大きさを変えたら覚える let rt = 0; new ResizeObserver(() => { clearTimeout(rt); rt = setTimeout(ksSaveBox, 400); }).observe(win); bar.addEventListener('click', (e) => { const b = e.target.closest('[data-ks]'); if (!b) return; if (b.dataset.ks === 'min') { win.classList.toggle('min'); b.textContent = win.classList.contains('min') ? '□' : '-'; return; } // 閉じる。取得中なら止めてから const api = ksPanel.api; if (api && api.isRunning()) { if (!confirm('取得の途中です。止めて閉じますか?(取った分は、もう一度開くと表に残っています)')) return; api.stop(); } if (api) api.unlink(); // ページ上の枠を消す host.style.setProperty('display', 'none', 'important'); }); ksPanel.api = __ksBootPanel(ksPanelChrome(), sr, resumeData || null); } // ---------- 起動 ---------- // ① リンク先のタブとして開かれたなら、パネルは出さず、元のタブの指示で読むだけ // ② 「次へ」でページが切り替わった直後なら、パネルを出して続きから取る // ③ それ以外は何も出さない。メニューを押した時だけ開く if (!ksClaimRemote()) { const rs = GM_getValue('ks_resume'); if (rs) { GM_deleteValue('ks_resume'); if (Date.now() - rs.t < 30000 && rs.host === location.hostname) ksOpenPanel(rs); } } GM_registerMenuCommand('この画面の一覧を取る', () => ksOpenPanel()); GM_registerMenuCommand('使い方のページを開く', () => GM_openInTab(KS_GUIDE_URL, { active: true })); })();