Files
ext-rclone-jav/content.js
T

543 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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__