// 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 "*" 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 = `${escapeOverlay(titleText)}${modeLabel ? `${escapeOverlay(modeLabel)}` : ""}×`; } else { header.innerHTML = `${escapeOverlay(titleText)}${modeLabel ? `${escapeOverlay(modeLabel)}` : ""}×`; } 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 ? `${escapeOverlay(h.match_reason)}` : ""; const card = document.createElement("div"); card.className = "rx-hit"; card.innerHTML = `
${escapeOverlay(filename)}
Path: ${escapeOverlay(dir)}
${escapeOverlay(h.source.toUpperCase())} ${escapeOverlay(h.size_human)} ${reason}
`; 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 = `Scanned: ${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 = `${escapeOverlay(result.no_match_title || "No match")}${result.no_match_detail ? `
${escapeOverlay(result.no_match_detail)}
` : ""}`; 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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__