Initial snapshot before step 6 options.js split

This commit is contained in:
admin
2026-05-22 21:05:21 +02:00
commit f8e781f0e9
26 changed files with 9741 additions and 0 deletions
+542
View File
@@ -0,0 +1,542 @@
// rclonex content script.
// Responsibilities:
// 1. On request, extract a JAV ID from the page via site adapters → URL → title.
// 2. Implement an interactive element picker so the user can fill in a selector
// by clicking on the page from the options UI.
//
// Wrapped in an IIFE + global flag so re-injection (e.g. via chrome.scripting.executeScript
// when manifest content_scripts already loaded it) is a no-op instead of a SyntaxError.
if (!window.__rclonex_loaded__) {
window.__rclonex_loaded__ = true;
(() => {
// Optional single trailing letter (e.g. IBW-902z) is matched but discarded —
// rc-jav's index already drops trailing letters too, so query "IBW-902" finds the file.
const ID_RE_DASHED = /\b([A-Za-z][A-Za-z0-9]{1,})-(\d{2,7})[a-zA-Z]?\b/;
const ID_RE_UNDASHED = /\b([A-Za-z][A-Za-z0-9]{1,})(\d{3,5})[a-zA-Z]?\b/;
// Built-in studio normalizers — applied BEFORE generic ID regex.
// Each entry: { re: RegExp, fmt: string ($1, $2 = capture groups) }.
// User-added normalizers from settings are tried before these.
const BUILTIN_ID_NORMALIZERS = [
// FC2-PPV in any dash configuration: FC2PPV12345, FC2-PPV12345, FC2-PPV-12345
{ re: /\bFC2-?PPV-?(\d{4,})\b/i, fmt: "FC2-PPV-$1" },
// Some sites display FC2 IDs without the PPV segment: FC2-1841460.
{ re: /\bFC2-(\d{4,})\b/i, fmt: "FC2-PPV-$1" },
];
const BUILTIN_SITE_ADAPTERS = [
{ host: "clearjav.com", selector: "div.meta-chip > h3.meta-chip__value" },
];
function applyNormalizers(text, userList) {
const all = [...(userList || []), ...BUILTIN_ID_NORMALIZERS];
for (const n of all) {
let re;
try { re = n.re instanceof RegExp ? n.re : new RegExp(n.re, "i"); } catch { continue; }
const m = text.match(re);
if (m) {
// Apply fmt with $1..$9 substitution
return n.fmt.replace(/\$(\d)/g, (_, i) => m[+i] || "");
}
}
return null;
}
let _userNormalizers = [];
async function loadUserNormalizers() {
try {
const { settings = {} } = await chrome.storage.sync.get("settings");
_userNormalizers = Array.isArray(settings.idNormalizers) ? settings.idNormalizers : [];
} catch { _userNormalizers = []; }
}
chrome.storage.onChanged?.addListener?.((changes, area) => {
if (area === "sync" && changes.settings) {
_userNormalizers = Array.isArray(changes.settings.newValue?.idNormalizers)
? changes.settings.newValue.idNormalizers : [];
}
});
loadUserNormalizers();
function normalizeId(text, userNormalizers = _userNormalizers) {
if (!text) return null;
// Try user-defined + built-in normalizers first (FC2-PPV-style oddballs).
const fromNormalizer = applyNormalizers(text, userNormalizers);
if (fromNormalizer) return fromNormalizer.toUpperCase();
let m = text.match(ID_RE_DASHED);
if (!m) m = text.match(ID_RE_UNDASHED);
if (!m) return null;
// Preserve the digits exactly as they appear (incl. leading zeros) — rc-jav --quick
// hands the glob "<ID>*" to rclone --include, which is literal, not numeric.
return `${m[1].toUpperCase()}-${m[2]}`;
}
function hostMatches(pattern, host) {
// Glob: '*' = any chars. Case-insensitive.
// Convenience: a bare domain (no '*.') ALSO matches any subdomain — and vice versa.
// "clearjav.com" matches both "clearjav.com" and "www.clearjav.com"
// "*.clearjav.com" matches both "www.clearjav.com" and bare "clearjav.com"
const patterns = [pattern];
if (pattern.startsWith("*.")) patterns.push(pattern.slice(2));
else if (!pattern.includes("*")) patterns.push("*." + pattern);
for (const p of patterns) {
const re = new RegExp("^" + p.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$", "i");
if (re.test(host)) return true;
}
return false;
}
function tryAdapters(adapters, userNormalizers = _userNormalizers) {
const host = location.hostname;
for (const a of adapters) {
if (!a.host || !a.selector) continue;
if (!hostMatches(a.host, host)) continue;
const selectors = a.selector.split(",").map((s) => s.trim()).filter(Boolean);
for (const sel of selectors) {
let el;
try { el = document.querySelector(sel); } catch { continue; }
if (!el) continue;
const text = (el.textContent || el.innerText || "").trim();
const id = normalizeId(text, userNormalizers);
if (id) return { id, source: "adapter", adapter: a.host, selector: sel, raw: text.slice(0, 200) };
}
}
return null;
}
function tryUrl(userNormalizers = _userNormalizers) {
const raw = decodeURIComponent(location.pathname + " " + location.search);
const id = normalizeId(raw, userNormalizers);
return id ? { id, source: "url", raw: raw.slice(0, 200) } : null;
}
function tryTitle(userNormalizers = _userNormalizers) {
const id = normalizeId(document.title, userNormalizers);
return id ? { id, source: "title", raw: document.title.slice(0, 200) } : null;
}
async function getAdapters() {
const { settings = {} } = await chrome.storage.sync.get("settings");
const user = Array.isArray(settings.siteAdapters) ? settings.siteAdapters : [];
const builtin = [...BUILTIN_SITE_ADAPTERS, ...(window.__RCLONEX_BUILTIN_ADAPTERS__ || [])];
// User entries override built-ins for the same host (user list iterated first).
return [...user, ...builtin];
}
async function tracePageExtraction(overrides = {}) {
const savedAdapters = await getAdapters();
const adapters = Array.isArray(overrides.adapters)
? [...overrides.adapters, ...BUILTIN_SITE_ADAPTERS, ...(window.__RCLONEX_BUILTIN_ADAPTERS__ || [])]
: savedAdapters;
const normalizers = Array.isArray(overrides.normalizers) ? overrides.normalizers : _userNormalizers;
const adapter = tryAdapters(adapters, normalizers);
const title = tryTitle(normalizers);
const url = tryUrl(normalizers);
return {
id: (adapter || title || url || {}).id || null,
source: (adapter || title || url || {}).source || "none",
selected: adapter || title || url || null,
stages: { adapter, title, url },
};
}
// ---------- picker ----------
let pickerActive = false;
let pickerOverlay = null;
let pickerHighlight = null;
function buildSelector(el) {
if (!el || el.nodeType !== 1) return null;
// Helper: does selector hit an element with the same JAV-ID-bearing text?
// We don't require strict uniqueness — adapter uses first match anyway, so as long
// as the FIRST match has the right textContent (or contains the same ID), we win.
const wantedText = (el.textContent || "").trim();
const wantedId = normalizeId(wantedText);
const matchesTarget = (sel) => {
let first;
try { first = document.querySelector(sel); } catch { return false; }
if (!first) return false;
if (first === el) return true;
const t = (first.textContent || "").trim();
if (wantedId && normalizeId(t) === wantedId) return true;
return t === wantedText;
};
const tag = el.tagName.toLowerCase();
const candidates = [];
// 1. #id if any
if (el.id) candidates.push("#" + CSS.escape(el.id));
// 2. tag + single class (each class on its own — pick shortest that works)
if (el.classList.length) {
for (const cls of el.classList) candidates.push(`${tag}.${CSS.escape(cls)}`);
// 3. tag + all classes combined
candidates.push(tag + Array.from(el.classList).map((c) => "." + CSS.escape(c)).join(""));
// 4. just .class (no tag) for each class
for (const cls of el.classList) candidates.push("." + CSS.escape(cls));
}
// 5. just tag (rarely unique but cheap)
candidates.push(tag);
// Try simplest first.
candidates.sort((a, b) => a.length - b.length);
for (const sel of candidates) {
if (matchesTarget(sel)) return sel;
}
// Fall back: build qualified ancestor path.
const parts = [];
let cur = el;
while (cur && cur.nodeType === 1 && cur.tagName.toLowerCase() !== "html") {
let part = cur.tagName.toLowerCase();
if (cur.id) { part += "#" + CSS.escape(cur.id); parts.unshift(part); break; }
if (cur.classList.length) {
part += Array.from(cur.classList).slice(0, 3).map((c) => "." + CSS.escape(c)).join("");
}
const siblings = cur.parentNode ? Array.from(cur.parentNode.children).filter((s) => s.tagName === cur.tagName) : [];
if (siblings.length > 1) {
part += `:nth-of-type(${siblings.indexOf(cur) + 1})`;
}
parts.unshift(part);
const candidate = parts.join(" > ");
if (matchesTarget(candidate)) return candidate;
cur = cur.parentElement;
}
return parts.join(" > ");
}
function startPicker() {
if (pickerActive) return;
pickerActive = true;
pickerOverlay = document.createElement("div");
pickerOverlay.style.cssText = "position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:2147483647;background:#ff8800;color:#000;padding:10px 16px;font:bold 14px/1.3 -apple-system,Segoe UI,sans-serif;border-radius:4px;box-shadow:0 4px 16px rgba(0,0,0,.5);pointer-events:none;max-width:90vw;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;";
pickerOverlay.textContent = "rclonex picker: hover an element, click to pick — Esc to cancel";
document.body.appendChild(pickerOverlay);
pickerHighlight = document.createElement("div");
pickerHighlight.style.cssText = "position:fixed;pointer-events:none;z-index:2147483646;border:3px dashed #ff8800;background:rgba(255,136,0,.25);box-shadow:0 0 0 9999px rgba(0,0,0,.35);box-sizing:border-box;transition:all 0.05s;";
document.body.appendChild(pickerHighlight);
document.addEventListener("mousemove", onPickerMove, true);
document.addEventListener("click", onPickerClick, true);
document.addEventListener("keydown", onPickerKey, true);
}
function stopPicker() {
if (!pickerActive) return;
pickerActive = false;
document.removeEventListener("mousemove", onPickerMove, true);
document.removeEventListener("click", onPickerClick, true);
document.removeEventListener("keydown", onPickerKey, true);
if (pickerOverlay) pickerOverlay.remove();
if (pickerHighlight) pickerHighlight.remove();
pickerOverlay = null;
pickerHighlight = null;
}
function onPickerMove(e) {
const el = document.elementFromPoint(e.clientX, e.clientY);
if (!el || el === pickerHighlight || el === pickerOverlay) return;
const r = el.getBoundingClientRect();
pickerHighlight.style.top = r.top + "px";
pickerHighlight.style.left = r.left + "px";
pickerHighlight.style.width = r.width + "px";
pickerHighlight.style.height = r.height + "px";
pickerOverlay.textContent = `rclonex: ${el.tagName.toLowerCase()}${el.id ? "#" + el.id : ""}${el.className && typeof el.className === "string" ? "." + el.className.trim().split(/\s+/).slice(0, 2).join(".") : ""} — click to pick, Esc to cancel`;
}
function onPickerClick(e) {
e.preventDefault();
e.stopPropagation();
const el = document.elementFromPoint(e.clientX, e.clientY);
if (!el || el === pickerHighlight || el === pickerOverlay) { stopPicker(); return; }
const selector = buildSelector(el);
const text = (el.textContent || "").trim().slice(0, 200);
const id = normalizeId(text);
chrome.runtime.sendMessage({
type: "picker-result",
host: location.hostname,
selector,
sample: text,
detectedId: id,
});
stopPicker();
}
function onPickerKey(e) {
if (e.key === "Escape") { stopPicker(); chrome.runtime.sendMessage({ type: "picker-cancelled" }); }
}
// ---------- message dispatch ----------
// ---------- overlay (auto-trigger hit toast) ----------
const OVERLAY_ID = "rclonex-overlay";
function showOverlay(result, opts = {}) {
if (!result) return;
const kind = opts.kind || "match";
if (kind === "match" && (!result.structured || result.structured.length === 0)) return;
document.getElementById(OVERLAY_ID)?.remove();
document.getElementById("rclonex-overlay-style")?.remove();
const position = opts.position || "top-right";
const durationMs = Math.max(500, Math.round((opts.duration || 5) * 1000));
const glow = !!opts.glow;
const glowColor = opts.glowColor || "#6ec1ff";
const glowBlur = Number.isFinite(opts.glowBlur) ? opts.glowBlur : 10;
const glowSpread = Number.isFinite(opts.glowSpread) ? opts.glowSpread : 0;
const glowOpacity = Number.isFinite(opts.glowOpacity) ? opts.glowOpacity : 0.35;
const hexToRgba = (hex, a) => {
const m = String(hex).match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
if (!m) return `rgba(110,193,255,${a})`;
return `rgba(${parseInt(m[1], 16)},${parseInt(m[2], 16)},${parseInt(m[3], 16)},${a})`;
};
const positions = {
"top-right": { top: "16px", right: "16px", bottom: "auto", left: "auto", enter: "translateY(-10px)" },
"top-left": { top: "16px", left: "16px", bottom: "auto", right: "auto", enter: "translateY(-10px)" },
"bottom-right": { bottom: "16px", right: "16px", top: "auto", left: "auto", enter: "translateY(10px)" },
"bottom-left": { bottom: "16px", left: "16px", top: "auto", right: "auto", enter: "translateY(10px)" },
};
const p = positions[position] || positions["top-right"];
const glowCss = glow
? `box-shadow: 0 6px 20px rgba(0,0,0,0.55), 0 0 ${glowBlur}px ${glowSpread}px ${hexToRgba(glowColor, glowOpacity)};`
: "box-shadow: 0 8px 30px rgba(0,0,0,0.6);";
const css = `
#${OVERLAY_ID} {
position: fixed;
top: ${p.top}; right: ${p.right}; bottom: ${p.bottom}; left: ${p.left};
width: 380px;
z-index: 2147483647; background: #1a1a1a; color: #ddd;
border: 1px solid #2a2a2a; border-radius: 6px;
font-family: -apple-system, Segoe UI, sans-serif; font-size: 13px;
${glowCss}
overflow: hidden;
opacity: 0; transform: ${p.enter};
transition: opacity 0.25s, transform 0.25s;
}
#${OVERLAY_ID}.show { opacity: 1; transform: translateY(0); }
#${OVERLAY_ID} .rx-header {
padding: 8px 12px; background: #1e3a1e; color: #afa;
font-size: 13px; font-weight: 600;
display: flex; align-items: center; gap: 8px;
}
#${OVERLAY_ID} .rx-title {
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
#${OVERLAY_ID} .rx-mode {
margin-left: auto;
background: rgba(0,0,0,0.28);
border: 1px solid rgba(255,255,255,0.18);
color: currentColor;
padding: 1px 6px;
border-radius: 10px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.4px;
flex-shrink: 0;
}
#${OVERLAY_ID} .rx-header.rx-no-match { background: #3a1e1e; color: #faa; }
#${OVERLAY_ID} .rx-subline {
padding: 8px 12px; background: #0d0d0d; color: #aaa;
font-size: 12px; font-family: Consolas, monospace;
word-break: break-all;
}
#${OVERLAY_ID} .rx-miss-detail {
padding: 8px 12px; background: #15110d; color: #ccb98f;
font-size: 11px; line-height: 1.45;
border-top: 1px solid #30251a;
}
#${OVERLAY_ID} .rx-close {
cursor: pointer; color: #8a8a8a; font-size: 16px;
width: 18px; height: 18px; line-height: 16px; text-align: center;
border-radius: 3px;
flex-shrink: 0;
}
#${OVERLAY_ID} .rx-close:hover { background: rgba(255,255,255,0.1); color: #fff; }
#${OVERLAY_ID} .rx-body { padding: 6px; background: #0d0d0d; max-height: 360px; overflow-y: auto; }
#${OVERLAY_ID} .rx-hit {
background: #161616; border: 1px solid #2a2a2a; border-radius: 4px;
padding: 10px; margin-bottom: 6px;
}
#${OVERLAY_ID} .rx-hit:last-child { margin-bottom: 0; }
#${OVERLAY_ID} .rx-file { color: #fff; font-weight: 600; font-size: 13px; word-break: break-all; margin: 0 0 4px; }
#${OVERLAY_ID} .rx-path { color: #aaa; font-size: 12px; font-family: Consolas, monospace; word-break: break-all; margin: 0 0 6px; line-height: 1.45; }
#${OVERLAY_ID} .rx-plabel { color: #555; font-weight: 600; }
#${OVERLAY_ID} .rx-meta { display: flex; align-items: center; gap: 8px; font-size: 11px; line-height: 1; }
#${OVERLAY_ID} .rx-size { color: #6ec1ff; font-weight: 600; }
#${OVERLAY_ID} .rx-src { background: #2a2a1a; color: #ffcc44; padding: 1px 6px; border-radius: 10px; font-size: 10px; font-weight: 600; letter-spacing: 0.3px; }
#${OVERLAY_ID} .rx-src.source { background: #1a3a1a; color: #66dd66; }
#${OVERLAY_ID} .rx-src.catalog { background: #1a2a3a; color: #66bbff; }
#${OVERLAY_ID} .rx-reason { background: #202a32; border: 1px solid #314453; color: #9dccff; padding: 1px 6px; border-radius: 10px; font-size: 10px; font-weight: 600; }
#${OVERLAY_ID} .rx-bar-wrap { height: 3px; background: #222; }
#${OVERLAY_ID} .rx-bar {
height: 100%; background: linear-gradient(90deg, #6ec1ff, #66dd66);
transform-origin: left; transform: scaleX(1);
animation: rxshrink ${durationMs}ms linear forwards;
}
@keyframes rxshrink { from { transform: scaleX(1); } to { transform: scaleX(0); } }
`;
let styleEl = document.getElementById("rclonex-overlay-style");
if (!styleEl) {
styleEl = document.createElement("style");
styleEl.id = "rclonex-overlay-style";
styleEl.textContent = css;
document.head.appendChild(styleEl);
}
const root = document.createElement("div");
root.id = OVERLAY_ID;
const idLabel = result.id || result.structured?.[0]?.jav_id || "?";
const cacheTag = result.cached && result.ts ? ` [session ${fmtOverlayAge(result.ts)}]` : "";
const modeLabel = result.cached ? "SESSION" : result.search_mode === "quick" ? "LIVE" : result.search_mode === "cached" ? "CACHE" : "";
const header = document.createElement("div");
header.className = "rx-header" + (kind === "no-match" ? " rx-no-match" : "");
const titleText = kind === "match"
? `${idLabel}${result.hits} hit(s)${cacheTag}`
: `${idLabel} — NOT IN LIBRARY${cacheTag}`;
if (kind === "match") {
header.innerHTML = `<span class="rx-title">${escapeOverlay(titleText)}</span>${modeLabel ? `<span class="rx-mode">${escapeOverlay(modeLabel)}</span>` : ""}<span class="rx-close" title="dismiss">×</span>`;
} else {
header.innerHTML = `<span class="rx-title">${escapeOverlay(titleText)}</span>${modeLabel ? `<span class="rx-mode">${escapeOverlay(modeLabel)}</span>` : ""}<span class="rx-close" title="dismiss">×</span>`;
}
root.appendChild(header);
if (kind === "match") {
const body = document.createElement("div");
body.className = "rx-body";
for (const h of result.structured) {
const filename = h.path.split("/").pop();
const idx = h.full_path.lastIndexOf("/");
const dir = idx >= 0 ? h.full_path.slice(0, idx) : h.full_path;
const srcCls = h.source === "Source" ? "rx-src source"
: h.source === "Catalog" ? "rx-src catalog" : "rx-src";
const confidence = h.match_confidence ? ` · ${h.match_confidence}` : "";
const reason = h.match_reason
? `<span class="rx-reason" title="Matched ${escapeOverlay(h.matched_query || h.jav_id || "")}${escapeOverlay(confidence)}">${escapeOverlay(h.match_reason)}</span>`
: "";
const card = document.createElement("div");
card.className = "rx-hit";
card.innerHTML = `
<div class="rx-file">${escapeOverlay(filename)}</div>
<div class="rx-path"><span class="rx-plabel">Path:</span> ${escapeOverlay(dir)}</div>
<div class="rx-meta">
<span class="${srcCls}">${escapeOverlay(h.source.toUpperCase())}</span>
<span class="rx-size">${escapeOverlay(h.size_human)}</span>
${reason}
</div>
`;
body.appendChild(card);
}
root.appendChild(body);
} else {
// No-match: a single sub-line saying what was scanned.
const sub = document.createElement("div");
sub.className = "rx-subline";
const remotes = (result.scanned_remotes && result.scanned_remotes.length)
? result.scanned_remotes.join(", ")
: (Object.keys(result.cache_meta || {})[0] || "library");
sub.innerHTML = `<span class="rx-plabel">Scanned:</span> ${escapeOverlay(remotes)}`;
root.appendChild(sub);
if (result.no_match_title || result.no_match_detail) {
const detail = document.createElement("div");
detail.className = "rx-miss-detail";
detail.innerHTML = `<strong>${escapeOverlay(result.no_match_title || "No match")}</strong>${result.no_match_detail ? `<div>${escapeOverlay(result.no_match_detail)}</div>` : ""}`;
root.appendChild(detail);
}
}
const barWrap = document.createElement("div");
barWrap.className = "rx-bar-wrap";
const bar = document.createElement("div");
bar.className = "rx-bar";
barWrap.appendChild(bar);
root.appendChild(barWrap);
document.body.appendChild(root);
requestAnimationFrame(() => root.classList.add("show"));
// Hover pauses the countdown
root.addEventListener("mouseenter", () => { bar.style.animationPlayState = "paused"; });
root.addEventListener("mouseleave", () => { bar.style.animationPlayState = "running"; });
// Close button
header.querySelector(".rx-close").addEventListener("click", (e) => {
e.stopPropagation();
dismissOverlay(root);
});
// Animation end → fade out
bar.addEventListener("animationend", () => dismissOverlay(root));
}
function dismissOverlay(root) {
root.classList.remove("show");
setTimeout(() => root.remove(), 300);
}
function escapeOverlay(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function fmtOverlayAge(ts) {
if (!ts) return "";
const s = Math.round((Date.now() - ts) / 1000);
if (s < 60) return `${s}s ago`;
if (s < 3600) return `${Math.round(s / 60)}m ago`;
return `${Math.round(s / 3600)}h ago`;
}
// ---------- message dispatch ----------
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "show-overlay") {
showOverlay(msg.result, {
kind: msg.kind || "match",
position: msg.position,
duration: msg.duration,
glow: msg.glow,
glowColor: msg.glowColor,
glowBlur: msg.glowBlur,
glowSpread: msg.glowSpread,
glowOpacity: msg.glowOpacity,
});
sendResponse({ ok: true });
return false;
}
if (msg.type === "extract-id") {
(async () => {
const trace = await tracePageExtraction();
sendResponse(trace.selected || { id: null, source: "none" });
})();
return true;
}
if (msg.type === "trace-extract-id") {
(async () => {
sendResponse(await tracePageExtraction({
adapters: msg.adapters,
normalizers: msg.normalizers,
}));
})();
return true;
}
if (msg.type === "start-picker") {
startPicker();
sendResponse({ ok: true });
return false;
}
if (msg.type === "stop-picker") {
stopPicker();
sendResponse({ ok: true });
return false;
}
});
})();
} // end if !__rclonex_loaded__