Files
admin 2d6a95682f Sync working tree before initial Gitea push
- File reorg: popup/options/bulk-check moved to src/ subdirs
- Shared modules: src/shared/id-extract.js, src/options/options-shared.js
- Host updates: rcjav-host.py + register/install scripts
- .gitignore expanded
2026-05-26 22:42:15 +02:00

510 lines
21 KiB
JavaScript
Raw Permalink 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;
(() => {
// ID-extraction primitives live in src/shared/id-extract.js (loaded by the
// manifest content_scripts[] entry before this file).
const { normalizeId: _normalizeId } = self.RCJAV_IDS;
const BUILTIN_SITE_ADAPTERS = [
{ host: "clearjav.com", selector: "div.meta-chip > h3.meta-chip__value" },
];
let _userNormalizers = [];
// Thin wrapper so callers without an explicit list pick up the live settings.
function normalizeId(text, userNormalizers = _userNormalizers) {
return _normalizeId(text, 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 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__