Collect video cards from eporner.com into a fullscreen sortable grid.
// ==UserScript==
// @name EPorner Scraper
// @namespace http://tampermonkey.net/
// @version 3.1
// @description Collect video cards from eporner.com into a fullscreen sortable grid.
// @author gentlemanan
// @license MIT
// @match https://www.eporner.com/*
// @match https://eporner.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// ==/UserScript==
(function () {
'use strict';
// ─── Constants ────────────────────────────────────────────────────────────
const STORAGE_KEY_LIST = 'ep_scraper_list';
const STORAGE_KEY_FILTERS = 'ep_scraper_filters';
// ─── State ────────────────────────────────────────────────────────────────
let savedList = loadList();
let chipFilters = loadFilters();
let filterInput = null;
let sortKey = 'lastSeen';
let sortAsc = false;
let filterDuration = 0; // minimum seconds; 0 = no filter
let filterSeen = 0; // minimum lastSeen ms timestamp; 0 = no filter
let filterRating = 0; // minimum rating percent; 0 = no filter
let filterViews = 0; // minimum view count; 0 = no filter
// ─── Persistence ──────────────────────────────────────────────────────────
function loadList() {
try { return JSON.parse(GM_getValue(STORAGE_KEY_LIST, '{}')); }
catch { return {}; }
}
function saveList() {
GM_setValue(STORAGE_KEY_LIST, JSON.stringify(savedList));
}
function loadFilters() {
try {
const parsed = JSON.parse(GM_getValue(STORAGE_KEY_FILTERS, '[]'));
return Array.isArray(parsed) ? parsed.filter(f => typeof f.value === 'string') : [];
} catch { return []; }
}
function saveFilters() {
GM_setValue(STORAGE_KEY_FILTERS, JSON.stringify(chipFilters));
}
// ─── Styles ───────────────────────────────────────────────────────────────
GM_addStyle(`
#ep-toggle {
position: fixed; right: 12px; bottom: 12px;
width: 48px; height: 48px;
background: #e74c3c; border: none;
color: #fff; cursor: pointer;
display: flex; align-items: center; justify-content: center;
border-radius: 50%; font-size: 22px; font-weight: bold;
z-index: 2147483646;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
}
#ep-toggle:hover { background: #c0392b; }
#ep-overlay {
position: fixed; inset: 0;
background: #0d0d0d;
z-index: 2147483647;
display: flex; flex-direction: column;
font-family: sans-serif; font-size: 13px; color: #ddd;
display: none;
}
#ep-overlay.open { display: flex; }
/* ── Toolbar ── */
#ep-toolbar {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px; background: #1a1a1a;
border-bottom: 1px solid #2a2a2a; flex-shrink: 0;
}
#ep-toolbar-title { font-weight: bold; font-size: 15px; color: #fff; }
#ep-count {
background: #e74c3c; color: #fff; border-radius: 10px;
padding: 1px 8px; font-size: 11px; font-weight: bold;
}
#ep-filter-input {
flex: 1; max-width: 340px;
background: #222; border: 1px solid #3a3a3a; border-radius: 4px;
color: #ddd; padding: 5px 10px; font-size: 12px;
}
#ep-filter-input::placeholder { color: #555; }
/* ── Sort bar ── */
#ep-sort-bar {
display: flex; align-items: center; gap: 6px;
padding: 6px 14px; background: #161616;
border-bottom: 1px solid #2a2a2a; flex-shrink: 0;
flex-wrap: wrap;
}
#ep-sort-bar-label { color: #555; font-size: 11px; margin-right: 2px; }
.ep-sort-btn {
background: #222; border: 1px solid #333; color: #888;
border-radius: 4px; padding: 3px 10px; font-size: 11px;
cursor: pointer; user-select: none; white-space: nowrap;
}
.ep-sort-btn:hover { color: #ddd; border-color: #555; }
.ep-sort-btn.active { background: #1a2a3a; color: #fff; border-color: #2980b9; }
.ep-sort-bar-divider { width: 1px; height: 18px; background: #2a2a2a; margin: 0 4px; }
.ep-filter-select {
background: #222; border: 1px solid #333; color: #888;
border-radius: 4px; padding: 3px 8px; font-size: 11px;
cursor: pointer; white-space: nowrap;
appearance: none; -webkit-appearance: none;
padding-right: 20px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5'%3E%3Cpath d='M0 0l4 5 4-5z' fill='%23666'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 6px center;
}
.ep-filter-select:hover { color: #ddd; border-color: #555; }
.ep-filter-select.active { background-color: #1a2a3a; color: #fff; border-color: #2980b9;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5'%3E%3Cpath d='M0 0l4 5 4-5z' fill='%23aaa'/%3E%3C/svg%3E");
}
/* ── Chip filter row ── */
#ep-chip-row {
display: flex; flex-wrap: wrap; gap: 5px; align-items: center;
padding: 5px 14px 6px; background: #1a1a1a;
border-bottom: 1px solid #2a2a2a;
min-height: 0;
}
#ep-chip-row:empty { display: none; }
.ep-chip {
display: inline-flex; align-items: center; gap: 4px;
padding: 2px 8px; border-radius: 12px; font-size: 11px;
font-weight: bold; cursor: pointer; user-select: none;
border: 1px solid transparent;
}
.ep-chip.pos {
background: #1a3a2a; color: #82e0aa; border-color: #2ecc71;
}
.ep-chip.neg {
background: #3a1a1a; color: #e07f7f; border-color: #c0392b;
text-decoration: line-through;
}
.ep-chip-remove { font-size: 13px; line-height: 1; opacity: 0.7; }
.ep-chip:hover .ep-chip-remove { opacity: 1; }
.ep-tb-btn {
padding: 6px 14px; border: none; border-radius: 4px;
cursor: pointer; font-size: 12px; font-weight: bold; color: #fff;
}
#ep-btn-settings {
background: none; border: none; color: #888;
cursor: pointer; font-size: 18px; line-height: 1; padding: 4px 6px;
}
#ep-btn-settings:hover { color: #fff; }
#ep-btn-close { background: #444; margin-left: auto; }
#ep-btn-close:hover { background: #666; }
/* ── Settings panel ── */
#ep-settings {
position: absolute; inset: 0;
background: #111; z-index: 10;
display: flex; flex-direction: column;
transform: translateX(100%);
transition: transform 0.25s ease;
overflow: hidden;
}
#ep-settings.open { transform: translateX(0); }
#ep-settings-header {
display: flex; align-items: center; gap: 8px;
padding: 10px 14px; background: #1a1a1a;
border-bottom: 1px solid #2a2a2a; flex-shrink: 0;
}
#ep-settings-back {
background: none; border: none; color: #aaa;
cursor: pointer; font-size: 22px; line-height: 1; padding: 0 4px;
}
#ep-settings-back:hover { color: #fff; }
#ep-settings-heading {
font-size: 14px; font-weight: bold; color: #fff;
}
#ep-settings-body {
flex: 1; overflow-y: auto; padding: 20px 24px;
display: flex; flex-direction: column; gap: 12px;
}
.ep-settings-row {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; padding: 14px 0; border-bottom: 1px solid #1e1e1e;
}
.ep-settings-row:last-child { border-bottom: none; }
.ep-settings-row-info { display: flex; flex-direction: column; gap: 3px; flex: 1; }
.ep-settings-row-label { font-size: 13px; color: #ddd; font-weight: bold; }
.ep-settings-row-desc { font-size: 11px; color: #555; }
.ep-settings-btn {
flex-shrink: 0; padding: 7px 18px; border: none; border-radius: 4px;
cursor: pointer; font-size: 12px; font-weight: bold; color: #fff;
min-width: 110px; text-align: center;
}
.ep-settings-btn.green { background: #27ae60; }
.ep-settings-btn.green:hover { background: #2ecc71; }
.ep-settings-btn.purple { background: #7d3c98; }
.ep-settings-btn.purple:hover { background: #9b59b6; }
.ep-settings-btn.red { background: #c0392b; }
.ep-settings-btn.red:hover { background: #e74c3c; }
.ep-settings-btn.orange { background: #d35400; }
.ep-settings-btn.orange:hover { background: #e67e22; }
.ep-settings-btn:disabled { background: #333; color: #666; cursor: default; }
/* ── Card grid ── */
#ep-grid-wrap {
flex: 1; overflow-y: auto; padding: 14px;
}
#ep-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
}
.ep-card {
background: #1a1a1a; border-radius: 6px; overflow: hidden;
display: flex; flex-direction: column;
transition: transform 0.15s, box-shadow 0.15s;
cursor: pointer;
}
.ep-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0,0,0,0.6);
}
/* Thumbnail area */
.ep-card-thumb-wrap {
position: relative; width: 100%; aspect-ratio: 16/9;
background: #000; overflow: hidden; flex-shrink: 0;
}
.ep-card-thumb {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.ep-card-preview {
position: absolute; inset: 0;
width: 100%; height: 100%; object-fit: cover;
opacity: 0; transition: opacity 0.15s ease;
pointer-events: none;
}
.ep-card-thumb-wrap:hover .ep-card-preview { opacity: 1; }
/* Tag badges overlaid on thumbnail */
.ep-card-tags {
position: absolute; bottom: 5px; right: 5px;
display: flex; gap: 3px; flex-wrap: wrap; justify-content: flex-end;
}
.ep-card-tag {
border-radius: 3px; padding: 1px 5px;
font-size: 10px; font-weight: bold; cursor: pointer;
user-select: none; border: 1px solid transparent;
}
.ep-card-tag.vr { background: rgba(120, 0, 180, 0.85); color: #e8c6ff; }
.ep-card-tag.uhd { background: rgba(180, 120, 0, 0.85); color: #ffe5a0; }
.ep-card-tag.def { background: rgba(30, 30, 30, 0.85); color: #a0c4e8; }
.ep-card-tag.active-pos { outline: 2px solid #2ecc71; }
.ep-card-tag.active-neg { outline: 2px solid #c0392b; opacity: 0.6; }
/* Card body */
.ep-card-body {
padding: 8px 10px 10px; display: flex; flex-direction: column; gap: 5px;
}
.ep-card-title {
font-size: 12px; font-weight: 600; color: #eee; line-height: 1.35;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden; text-overflow: ellipsis;
}
.ep-card-title a {
color: inherit; text-decoration: none;
}
.ep-card-title a:hover { color: #7fb3d3; }
.ep-card-stats {
display: flex; gap: 8px; align-items: center;
font-size: 11px; color: #777; flex-wrap: wrap;
}
.ep-card-stat { display: flex; align-items: center; gap: 3px; }
.ep-card-stat-icon { font-size: 10px; opacity: 0.7; }
.ep-card-uploader {
font-size: 11px;
}
.ep-card-uploader-btn {
background: none; border: none; padding: 0;
color: #666; cursor: pointer; font-size: 11px;
font-family: inherit;
}
.ep-card-uploader-btn:hover { color: #aaa; }
.ep-card-uploader-btn.active-pos { color: #82e0aa; }
.ep-card-uploader-btn.active-neg { color: #e07f7f; }
.ep-card-lastseen {
font-size: 10px; color: #444;
}
.ep-card-placeholder {
width: 100%; aspect-ratio: 16/9; background: #1a1a1a;
display: flex; align-items: center; justify-content: center;
color: #444; font-size: 28px; flex-shrink: 0;
}
.ep-empty-msg {
grid-column: 1 / -1; text-align: center;
color: #444; padding: 60px; font-size: 14px;
}
/* ── Detail panel ── */
#ep-detail {
position: absolute; inset: 0;
background: #111; z-index: 10;
display: flex; flex-direction: column;
transform: translateX(100%);
transition: transform 0.25s ease;
overflow: hidden;
}
#ep-detail.open { transform: translateX(0); }
#ep-detail-header {
display: flex; align-items: center; gap: 8px;
padding: 10px 14px; background: #1a1a1a;
border-bottom: 1px solid #2a2a2a; flex-shrink: 0;
}
#ep-detail-back {
background: none; border: none; color: #aaa;
cursor: pointer; font-size: 22px; line-height: 1; padding: 0 4px;
}
#ep-detail-back:hover { color: #fff; }
#ep-detail-heading {
flex: 1; font-size: 14px; font-weight: bold; color: #fff;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
#ep-detail-body {
flex: 1; overflow-y: auto; padding: 16px;
display: flex; flex-direction: column; gap: 16px;
}
#ep-detail-thumb-col { width: 100%; flex-shrink: 0; }
#ep-detail-thumb-wrap {
width: 100%; aspect-ratio: 16/9;
background: #000; border-radius: 8px; overflow: hidden;
}
#ep-detail-thumb-wrap .ep-card-thumb-wrap { width: 100%; height: 100%; aspect-ratio: unset; }
#ep-detail-thumb-wrap .ep-card-thumb,
#ep-detail-thumb-wrap .ep-card-preview { object-fit: contain; }
#ep-detail-rows-col { display: flex; flex-direction: column; gap: 0; }
.ep-detail-row {
display: flex; gap: 10px; align-items: flex-start;
font-size: 12px; border-bottom: 1px solid #1e1e1e; padding: 7px 0;
}
.ep-detail-row:last-child { border-bottom: none; }
.ep-detail-label {
width: 90px; flex-shrink: 0; color: #555; font-size: 11px; padding-top: 1px;
}
.ep-detail-value { flex: 1; color: #ccc; word-break: break-all; }
.ep-detail-value a { color: #7fb3d3; text-decoration: none; }
.ep-detail-value a:hover { text-decoration: underline; }
.ep-detail-tags { display: flex; gap: 4px; flex-wrap: wrap; }
#ep-detail-open-btn {
margin: 0 14px 14px; padding: 10px;
background: #2980b9; color: #fff; border: none;
border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: bold;
flex-shrink: 0;
}
#ep-detail-open-btn:hover { background: #3498db; }
`);
// ─── Filter & sort helpers ────────────────────────────────────────────────
function getCardTags(cardEl) {
return Array.from(cardEl.querySelectorAll('.mvhdico span'))
.map(s => s.textContent.trim());
}
function getCardText(cardEl) {
const title = cardEl.querySelector('.mbtit a')?.textContent.trim() ?? '';
const uploader = cardEl.querySelector('.mb-uploader a')?.textContent.trim() || 'unknown';
return [title, uploader, ...getCardTags(cardEl)].join(' ');
}
function cardHasTags(cardEl) {
return cardEl.querySelectorAll('.mvhdico span').length > 0;
}
function entryMatchesFilter(entry) {
const text = entry.searchText ?? '';
const pattern = filterInput ? filterInput.value.trim() : '';
if (pattern) {
try {
if (!new RegExp(pattern, 'i').test(text)) return false;
} catch {
if (!text.toLowerCase().includes(pattern.toLowerCase())) return false;
}
}
if (filterDuration > 0 && durationToSeconds(entry.duration) < filterDuration) return false;
if (filterRating > 0 && ratingToNum(entry.rating) < filterRating) return false;
if (filterViews > 0 && viewsToNum(entry.views) < filterViews) return false;
if (filterSeen > 0) {
const seen = entry.lastSeen ? new Date(entry.lastSeen).getTime() : 0;
if (seen < filterSeen) return false;
}
return entryMatchesChips(entry);
}
// ─── Chip filters ─────────────────────────────────────────────────────────
function toggleChipFilter(value) {
const existing = chipFilters.find(f => f.value === value);
if (!existing) {
chipFilters.push({ value, negated: false });
} else if (!existing.negated) {
existing.negated = true;
} else {
chipFilters = chipFilters.filter(f => f.value !== value);
}
saveFilters();
renderChips();
refreshGrid();
}
function chipStateFor(value) {
const f = chipFilters.find(f => f.value === value);
if (!f) return null;
return f.negated ? 'neg' : 'pos';
}
function entryMatchesChips(entry) {
const text = (entry.searchText ?? '').toLowerCase();
return chipFilters.every(f => {
const has = text.includes(f.value.toLowerCase());
return f.negated ? !has : has;
});
}
function applyChipClass(el, value) {
el.classList.remove('active-pos', 'active-neg');
const state = chipStateFor(value);
if (state) el.classList.add(`active-${state}`);
}
function renderChips() {
const row = document.getElementById('ep-chip-row');
if (!row) return;
row.innerHTML = '';
chipFilters.forEach(f => {
const chip = document.createElement('span');
chip.className = `ep-chip ${f.negated ? 'neg' : 'pos'}`;
chip.title = f.negated ? 'Click to remove' : 'Click to negate';
chip.innerHTML = `${f.negated ? '✕ ' : ''}${f.value}<span class="ep-chip-remove">×</span>`;
chip.addEventListener('click', () => toggleChipFilter(f.value));
row.appendChild(chip);
});
document.querySelectorAll('.ep-card-tag, .ep-card-uploader-btn').forEach(el => {
if (el.dataset.filterValue) applyChipClass(el, el.dataset.filterValue);
});
}
function timeAgo(isoStr) {
if (!isoStr) return '';
const sec = Math.floor((Date.now() - new Date(isoStr)) / 1000);
if (sec < 60) return `${sec}s ago`;
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
if (sec < 86400 * 30) return `${Math.floor(sec / 86400)}d ago`;
if (sec < 86400 * 365) return `${Math.floor(sec / (86400 * 30))}mo ago`;
return `${Math.floor(sec / (86400 * 365))}y ago`;
}
// Parse "MM:SS" or "HH:MM:SS" to total seconds for numeric sort.
function durationToSeconds(str) {
if (!str) return 0;
return str.split(':').reduce((acc, v) => acc * 60 + parseInt(v || 0, 10), 0);
}
// Parse "96%" → 96
function ratingToNum(str) {
return parseFloat(str) || 0;
}
// Parse "1,234" or "1.2K" → number
function viewsToNum(str) {
if (!str) return 0;
const s = str.replace(/,/g, '');
if (s.endsWith('K')) return parseFloat(s) * 1000;
if (s.endsWith('M')) return parseFloat(s) * 1000000;
return parseFloat(s) || 0;
}
function getSortValue(entry, key) {
switch (key) {
case 'title': return entry.title.toLowerCase();
case 'duration': return durationToSeconds(entry.duration);
case 'rating': return ratingToNum(entry.rating);
case 'views': return viewsToNum(entry.views);
case 'uploader': return (entry.uploader ?? 'unknown').toLowerCase();
case 'savedAt': return entry.savedAt ?? '';
case 'lastSeen': return entry.lastSeen ?? entry.savedAt ?? '';
default: return '';
}
}
// ─── Data extraction ──────────────────────────────────────────────────────
// e.g. .../16917544/1_240.jpg → .../16917544/16917544-preview.webm
function thumbToPreview(thumbSrc, id) {
if (!thumbSrc || !id || !thumbSrc.startsWith('http')) return null;
const base = thumbSrc.substring(0, thumbSrc.lastIndexOf('/'));
return `${base}/${id}-preview.webm`;
}
function extractEntry(cardEl) {
const id = cardEl.dataset.id;
if (!id) return null;
const linkEl = cardEl.querySelector('.mbimg a');
const imgEl = cardEl.querySelector('.mbimg img');
const titleEl = cardEl.querySelector('.mbtit a');
const durEl = cardEl.querySelector('.mbtim');
const rateEl = cardEl.querySelector('.mbrate');
const viewsEl = cardEl.querySelector('.mbvie');
const uploaderEl = cardEl.querySelector('.mb-uploader a');
// Prefer data-src over src — the site lazy-loads images so src may be a placeholder GIF.
const thumbSrc = imgEl
? (imgEl.getAttribute('data-src') || (imgEl.src.startsWith('http') ? imgEl.src : null))
: null;
const now = new Date().toISOString();
return {
id,
href: linkEl ? 'https://www.eporner.com' + linkEl.getAttribute('href') : null,
thumb: thumbSrc,
title: titleEl ? titleEl.textContent.trim() : `Video ${id}`,
duration: durEl ? durEl.textContent.trim() : '',
rating: rateEl ? rateEl.textContent.trim() : '',
views: viewsEl ? viewsEl.textContent.trim() : '',
uploader: uploaderEl ? uploaderEl.textContent.trim() : 'unknown',
uploaderHref: uploaderEl ? uploaderEl.getAttribute('href') : null,
tags: getCardTags(cardEl),
searchText: getCardText(cardEl),
previewUrl: thumbSrc ? thumbToPreview(thumbSrc, id) : null,
foundAt: window.location.href,
savedAt: now,
lastSeen: now,
};
}
// ─── Tag class helper ─────────────────────────────────────────────────────
function tagCssClass(tag) {
if (tag === 'VR') return 'vr';
if (tag.startsWith('4K') || tag.includes('2160')) return 'uhd';
return 'def';
}
// ─── Thumb + video preview builder ───────────────────────────────────────
function buildThumbWrap(thumbSrc, previewUrl, onClick) {
const wrap = document.createElement('div');
wrap.className = 'ep-card-thumb-wrap';
if (onClick) wrap.addEventListener('click', onClick);
const img = document.createElement('img');
img.className = 'ep-card-thumb';
img.src = thumbSrc;
img.alt = '';
img.loading = 'lazy';
wrap.appendChild(img);
if (previewUrl) {
let vid = null;
wrap.addEventListener('mouseenter', () => {
if (!vid) {
vid = document.createElement('video');
vid.className = 'ep-card-preview';
vid.muted = true; vid.loop = true; vid.playsInline = true; vid.preload = 'none';
vid.src = previewUrl;
wrap.appendChild(vid);
}
vid.play().catch(() => {});
});
wrap.addEventListener('mouseleave', () => { if (vid) { vid.pause(); vid.currentTime = 0; } });
}
return wrap;
}
// ─── Detail panel ─────────────────────────────────────────────────────────
function showDetail(entry) {
document.getElementById('ep-detail-heading').textContent = entry.title;
const thumbWrap = document.getElementById('ep-detail-thumb-wrap');
thumbWrap.innerHTML = '';
if (entry.thumb) {
const previewUrl = entry.previewUrl || thumbToPreview(entry.thumb, entry.id);
const wrap = buildThumbWrap(entry.thumb, previewUrl, null);
wrap.style.cssText = 'width:100%;height:100%;aspect-ratio:16/9;border-radius:8px;cursor:default;';
thumbWrap.appendChild(wrap);
}
const tagsHtml = entry.tags
.map(t => `<span class="ep-card-tag ${tagCssClass(t)}" style="cursor:default">${t}</span>`)
.join('');
const uploaderHtml = entry.uploaderHref
? `<a href="https://www.eporner.com${entry.uploaderHref}" target="_blank" rel="noopener">${entry.uploader}</a>`
: entry.uploader;
document.getElementById('ep-detail-rows').innerHTML = `
<div class="ep-detail-row">
<span class="ep-detail-label">Title</span>
<span class="ep-detail-value">${entry.title}</span>
</div>
<div class="ep-detail-row">
<span class="ep-detail-label">Tags</span>
<span class="ep-detail-value ep-detail-tags">${tagsHtml || '—'}</span>
</div>
<div class="ep-detail-row">
<span class="ep-detail-label">Duration</span>
<span class="ep-detail-value">${entry.duration || '—'}</span>
</div>
<div class="ep-detail-row">
<span class="ep-detail-label">Rating</span>
<span class="ep-detail-value">${entry.rating || '—'}</span>
</div>
<div class="ep-detail-row">
<span class="ep-detail-label">Views</span>
<span class="ep-detail-value">${entry.views || '—'}</span>
</div>
<div class="ep-detail-row">
<span class="ep-detail-label">Uploader</span>
<span class="ep-detail-value">${uploaderHtml}</span>
</div>
<div class="ep-detail-row">
<span class="ep-detail-label">Video ID</span>
<span class="ep-detail-value">${entry.id}</span>
</div>
<div class="ep-detail-row">
<span class="ep-detail-label">Found on</span>
<span class="ep-detail-value"><a href="${entry.foundAt}" target="_blank" rel="noopener">${entry.foundAt}</a></span>
</div>
<div class="ep-detail-row">
<span class="ep-detail-label">Saved at</span>
<span class="ep-detail-value">${entry.savedAt ? new Date(entry.savedAt).toLocaleString() : '—'}</span>
</div>
<div class="ep-detail-row">
<span class="ep-detail-label">Last seen</span>
<span class="ep-detail-value">${entry.lastSeen ? new Date(entry.lastSeen).toLocaleString() : '—'}</span>
</div>
<div class="ep-detail-row">
<span class="ep-detail-label">Search text</span>
<span class="ep-detail-value">${entry.searchText}</span>
</div>
`;
document.getElementById('ep-detail-open-btn').onclick = () =>
window.open(entry.href, '_blank', 'noopener');
document.getElementById('ep-detail').classList.add('open');
}
function hideDetail() {
document.getElementById('ep-detail').classList.remove('open');
}
// ─── Card rendering ───────────────────────────────────────────────────────
const SORT_OPTIONS = [
{ key: 'lastSeen', label: 'Last Seen' },
{ key: 'savedAt', label: 'Saved' },
{ key: 'title', label: 'Title' },
{ key: 'duration', label: 'Duration' },
{ key: 'rating', label: 'Rating' },
{ key: 'views', label: 'Views' },
{ key: 'uploader', label: 'Uploader' },
];
function renderCard(entry) {
const card = document.createElement('div');
card.className = 'ep-card';
// ── Thumbnail area ──
if (entry.thumb) {
const previewUrl = entry.previewUrl || thumbToPreview(entry.thumb, entry.id);
const thumbWrap = buildThumbWrap(entry.thumb, previewUrl, () => showDetail(entry));
// Tag badges overlaid on thumbnail
if (entry.tags.length > 0) {
const tagsEl = document.createElement('div');
tagsEl.className = 'ep-card-tags';
entry.tags.forEach(t => {
const badge = document.createElement('span');
badge.className = `ep-card-tag ${tagCssClass(t)}`;
badge.dataset.filterValue = t;
badge.textContent = t;
applyChipClass(badge, t);
badge.addEventListener('click', e => { e.stopPropagation(); toggleChipFilter(t); });
tagsEl.appendChild(badge);
});
thumbWrap.appendChild(tagsEl);
}
card.appendChild(thumbWrap);
} else {
const ph = document.createElement('div');
ph.className = 'ep-card-placeholder';
ph.textContent = '▶';
card.appendChild(ph);
}
// ── Card body ──
const body = document.createElement('div');
body.className = 'ep-card-body';
// Title
const titleEl = document.createElement('div');
titleEl.className = 'ep-card-title';
titleEl.innerHTML = `<a href="${entry.href || '#'}" target="_blank" rel="noopener" title="${entry.title}">${entry.title}</a>`;
body.appendChild(titleEl);
// Stats row: duration, rating, views
const statDefs = [['⏱', entry.duration], ['★', entry.rating], ['👁', entry.views]];
const statsHtml = statDefs.filter(([, v]) => v)
.map(([icon, v]) => `<span class="ep-card-stat"><span class="ep-card-stat-icon">${icon}</span>${v}</span>`)
.join('');
if (statsHtml) {
const stats = document.createElement('div');
stats.className = 'ep-card-stats';
stats.innerHTML = statsHtml;
body.appendChild(stats);
}
// Uploader
const upDiv = document.createElement('div');
upDiv.className = 'ep-card-uploader';
const upBtn = document.createElement('button');
upBtn.className = 'ep-card-uploader-btn';
upBtn.dataset.filterValue = entry.uploader;
upBtn.textContent = entry.uploader;
applyChipClass(upBtn, entry.uploader);
upBtn.addEventListener('click', e => { e.stopPropagation(); toggleChipFilter(entry.uploader); });
upDiv.appendChild(upBtn);
body.appendChild(upDiv);
const ago = timeAgo(entry.lastSeen ?? entry.savedAt);
if (ago) {
const agoEl = document.createElement('div');
agoEl.className = 'ep-card-lastseen';
agoEl.textContent = ago;
body.appendChild(agoEl);
}
card.appendChild(body);
return card;
}
function emptyMsg(text) {
const el = document.createElement('div');
el.className = 'ep-empty-msg';
el.textContent = text;
return el;
}
function refreshGrid() {
const grid = document.getElementById('ep-grid');
const bar = document.getElementById('ep-sort-bar');
if (!grid) return;
// Rebuild sort bar
bar.innerHTML = `<span id="ep-sort-bar-label">Sort:</span>`;
SORT_OPTIONS.forEach(opt => {
const btn = document.createElement('button');
btn.className = `ep-sort-btn${sortKey === opt.key ? ' active' : ''}`;
const arrow = sortKey === opt.key ? (sortAsc ? ' ▲' : ' ▼') : '';
btn.textContent = opt.label + arrow;
btn.addEventListener('click', () => {
sortAsc = sortKey === opt.key ? !sortAsc : (opt.key === 'title' || opt.key === 'uploader');
sortKey = opt.key;
refreshGrid();
});
bar.appendChild(btn);
});
// Divider
const div1 = document.createElement('div');
div1.className = 'ep-sort-bar-divider';
bar.appendChild(div1);
// Duration dropdown
const durSel = document.createElement('select');
durSel.className = `ep-filter-select${filterDuration > 0 ? ' active' : ''}`;
durSel.title = 'Filter by duration';
[['Duration', 0], ['> 5 min', 300], ['> 30 min', 1800], ['> 1 hour', 3600], ['> 2 hours', 7200]]
.forEach(([label, val]) => {
const o = document.createElement('option');
o.value = val; o.textContent = label;
if (val === filterDuration) o.selected = true;
durSel.appendChild(o);
});
durSel.addEventListener('change', () => {
filterDuration = parseInt(durSel.value, 10);
durSel.className = `ep-filter-select${filterDuration > 0 ? ' active' : ''}`;
refreshGrid();
});
bar.appendChild(durSel);
// Seen dropdown
const now = Date.now();
const seenSel = document.createElement('select');
seenSel.className = `ep-filter-select${filterSeen > 0 ? ' active' : ''}`;
seenSel.title = 'Filter by last seen';
[
['Last seen', 0],
['Last minute', now - 60_000],
['Last hour', now - 3_600_000],
['Today', now - 86_400_000],
['This week', now - 7 * 86_400_000],
['This month', now - 30 * 86_400_000],
].forEach(([label, val]) => {
const o = document.createElement('option');
o.value = val; o.textContent = label;
if (val === filterSeen) o.selected = true;
seenSel.appendChild(o);
});
seenSel.addEventListener('change', () => {
filterSeen = parseInt(seenSel.value, 10);
seenSel.className = `ep-filter-select${filterSeen > 0 ? ' active' : ''}`;
refreshGrid();
});
bar.appendChild(seenSel);
// Rating dropdown
const ratSel = document.createElement('select');
ratSel.className = `ep-filter-select${filterRating > 0 ? ' active' : ''}`;
ratSel.title = 'Filter by minimum rating';
[['Rating', 0], ['≥ 70%', 70], ['≥ 80%', 80], ['≥ 90%', 90], ['≥ 95%', 95]]
.forEach(([label, val]) => {
const o = document.createElement('option');
o.value = val; o.textContent = label;
if (val === filterRating) o.selected = true;
ratSel.appendChild(o);
});
ratSel.addEventListener('change', () => {
filterRating = parseInt(ratSel.value, 10);
ratSel.className = `ep-filter-select${filterRating > 0 ? ' active' : ''}`;
refreshGrid();
});
bar.appendChild(ratSel);
// Views dropdown
const viewSel = document.createElement('select');
viewSel.className = `ep-filter-select${filterViews > 0 ? ' active' : ''}`;
viewSel.title = 'Filter by minimum views';
[['Views', 0], ['≥ 1K', 1000], ['≥ 10K', 10000], ['≥ 100K', 100000], ['≥ 1M', 1000000]]
.forEach(([label, val]) => {
const o = document.createElement('option');
o.value = val; o.textContent = label;
if (val === filterViews) o.selected = true;
viewSel.appendChild(o);
});
viewSel.addEventListener('change', () => {
filterViews = parseInt(viewSel.value, 10);
viewSel.className = `ep-filter-select${filterViews > 0 ? ' active' : ''}`;
refreshGrid();
});
bar.appendChild(viewSel);
grid.innerHTML = '';
const entries = Object.values(savedList);
document.getElementById('ep-count').textContent = entries.length;
if (entries.length === 0) { grid.appendChild(emptyMsg('No videos collected yet. Browse eporner and click Scan.')); return; }
const visible = entries.filter(entryMatchesFilter);
if (visible.length === 0) { grid.appendChild(emptyMsg('No matches for current filter.')); return; }
const cardObserver = new IntersectionObserver((entries, obs) => {
entries.forEach(({ isIntersecting, target }) => {
if (!isIntersecting) return;
obs.unobserve(target);
const entry = target._epEntry;
if (entry) target.replaceWith(renderCard(entry));
});
}, { root: document.getElementById('ep-grid-wrap'), rootMargin: '200px' });
visible
.sort((a, b) => {
const av = getSortValue(a, sortKey);
const bv = getSortValue(b, sortKey);
const cmp = av < bv ? -1 : av > bv ? 1 : 0;
return sortAsc ? cmp : -cmp;
})
.forEach(entry => {
const placeholder = document.createElement('div');
placeholder.className = 'ep-card-placeholder';
placeholder.style.cssText = 'aspect-ratio:16/9;height:auto;';
placeholder._epEntry = entry;
cardObserver.observe(placeholder);
grid.appendChild(placeholder);
});
}
// ─── Overlay DOM ──────────────────────────────────────────────────────────
function createOverlay() {
if (document.getElementById('ep-overlay')) return;
// Floating toggle button
const toggle = document.createElement('button');
toggle.id = 'ep-toggle';
toggle.textContent = '≡';
toggle.title = 'EPorner Scraper';
document.body.appendChild(toggle);
// Fullscreen overlay
const overlay = document.createElement('div');
overlay.id = 'ep-overlay';
overlay.innerHTML = `
<div id="ep-toolbar">
<span id="ep-toolbar-title">EPorner Scraper</span>
<span id="ep-count">0</span>
<input id="ep-filter-input" type="text" placeholder="Filter by regex, e.g. VR|4K">
<button id="ep-btn-settings" title="Settings">⚙</button>
<button class="ep-tb-btn" id="ep-btn-close">✕ Close</button>
</div>
<div id="ep-sort-bar"></div>
<div id="ep-chip-row"></div>
<div id="ep-grid-wrap">
<div id="ep-grid"></div>
</div>
`;
// Detail panel (slides over the overlay)
const detail = document.createElement('div');
detail.id = 'ep-detail';
detail.innerHTML = `
<div id="ep-detail-header">
<button id="ep-detail-back">←</button>
<span id="ep-detail-heading"></span>
</div>
<div id="ep-detail-body">
<div id="ep-detail-thumb-col">
<div id="ep-detail-thumb-wrap"></div>
</div>
<div id="ep-detail-rows-col">
<div id="ep-detail-rows"></div>
</div>
</div>
<button id="ep-detail-open-btn">Open Video ↗</button>
`;
overlay.appendChild(detail);
// Settings panel (slides over the overlay)
const settings = document.createElement('div');
settings.id = 'ep-settings';
settings.innerHTML = `
<div id="ep-settings-header">
<button id="ep-settings-back">←</button>
<span id="ep-settings-heading">Settings</span>
</div>
<div id="ep-settings-body">
<div class="ep-settings-row">
<div class="ep-settings-row-info">
<span class="ep-settings-row-label">Scan Page</span>
<span class="ep-settings-row-desc">Collect all video cards visible on the current page and refresh metadata for already-saved ones.</span>
</div>
<button class="ep-settings-btn green" id="ep-btn-scan">Scan Page</button>
</div>
<div class="ep-settings-row">
<div class="ep-settings-row-info">
<span class="ep-settings-row-label">Migrate</span>
<span class="ep-settings-row-desc">Backfill missing fields (savedAt, lastSeen, uploader, searchText) on entries captured before schema updates.</span>
</div>
<button class="ep-settings-btn purple" id="ep-btn-migrate">Migrate</button>
</div>
<div class="ep-settings-row">
<div class="ep-settings-row-info">
<span class="ep-settings-row-label">Trim to Filter</span>
<span class="ep-settings-row-desc">Delete all entries that do not match the current chip filters and text filter. Calculates disk savings before committing.</span>
</div>
<button class="ep-settings-btn orange" id="ep-btn-trim">Analyse</button>
</div>
<div class="ep-settings-row">
<div class="ep-settings-row-info">
<span class="ep-settings-row-label">Clear History</span>
<span class="ep-settings-row-desc">Permanently delete all saved videos and reset all chip filters. This cannot be undone.</span>
</div>
<button class="ep-settings-btn red" id="ep-btn-clear">Clear History</button>
</div>
<div class="ep-settings-row">
<div class="ep-settings-row-info">
<span class="ep-settings-row-label">Export Data</span>
<span class="ep-settings-row-desc">Download all saved videos and chip filters as a JSON file.</span>
</div>
<button class="ep-settings-btn green" id="ep-btn-export">Export</button>
</div>
<div class="ep-settings-row">
<div class="ep-settings-row-info">
<span class="ep-settings-row-label">Import Data</span>
<span class="ep-settings-row-desc">Merge a previously exported JSON file into the current collection. Existing entries are kept; duplicates are updated.</span>
</div>
<button class="ep-settings-btn purple" id="ep-btn-import">Import</button>
<input type="file" id="ep-import-file" accept=".json" style="display:none">
</div>
</div>
`;
overlay.appendChild(settings);
document.body.appendChild(overlay);
filterInput = document.getElementById('ep-filter-input');
const hideSettings = () => settings.classList.remove('open');
toggle.addEventListener('click', () => overlay.classList.toggle('open'));
document.getElementById('ep-btn-close').addEventListener('click', () => overlay.classList.remove('open'));
document.getElementById('ep-detail-back').addEventListener('click', hideDetail);
document.getElementById('ep-btn-settings').addEventListener('click', () => settings.classList.add('open'));
document.getElementById('ep-settings-back').addEventListener('click', hideSettings);
filterInput.addEventListener('input', () => refreshGrid());
document.getElementById('ep-btn-scan').addEventListener('click', () => {
const found = scanPage();
const btn = document.getElementById('ep-btn-scan');
btn.textContent = `+${found} found`;
setTimeout(() => { btn.textContent = 'Scan Page'; }, 1500);
});
document.getElementById('ep-btn-migrate').addEventListener('click', () => {
let patched = 0;
const fallbackDate = new Date().toISOString();
Object.values(savedList).forEach(entry => {
let changed = false;
if (!entry.savedAt) { entry.savedAt = fallbackDate; changed = true; }
if (!entry.lastSeen) { entry.lastSeen = entry.savedAt; changed = true; }
if (!entry.uploader) { entry.uploader = 'unknown'; changed = true; }
if (!entry.searchText) {
entry.searchText = [entry.title ?? '', entry.uploader, ...(entry.tags ?? [])].join(' ');
changed = true;
}
if (changed) patched++;
});
if (patched > 0) { saveList(); refreshGrid(); }
const btn = document.getElementById('ep-btn-migrate');
btn.textContent = patched > 0 ? `Migrated ${patched}` : 'Up to date';
setTimeout(() => { btn.textContent = 'Migrate'; }, 2000);
});
document.getElementById('ep-btn-trim').addEventListener('click', () => {
const btn = document.getElementById('ep-btn-trim');
const allEntries = Object.values(savedList);
const toRemove = allEntries.filter(e => !entryMatchesFilter(e));
if (toRemove.length === 0) {
btn.textContent = 'Nothing to trim';
setTimeout(() => { btn.textContent = 'Analyse'; btn.className = 'ep-settings-btn orange'; }, 2500);
return;
}
const bytes = new TextEncoder().encode(JSON.stringify(toRemove)).length;
const kb = (bytes / 1024).toFixed(1);
if (btn.dataset.confirmed !== '1') {
btn.dataset.confirmed = '1';
btn.className = 'ep-settings-btn red';
btn.textContent = `Delete ${toRemove.length} (${kb} KB)?`;
setTimeout(() => {
if (btn.dataset.confirmed === '1') {
btn.dataset.confirmed = '0';
btn.className = 'ep-settings-btn orange';
btn.textContent = 'Analyse';
}
}, 4000);
return;
}
toRemove.forEach(e => delete savedList[e.id]);
btn.dataset.confirmed = '0';
saveList();
refreshGrid();
btn.className = 'ep-settings-btn orange';
btn.textContent = `Removed ${toRemove.length}`;
setTimeout(() => { btn.textContent = 'Analyse'; }, 2000);
});
document.getElementById('ep-btn-clear').addEventListener('click', () => {
if (!confirm('Clear all saved video history? This cannot be undone.')) return;
savedList = {};
chipFilters = [];
saveList();
saveFilters();
renderChips();
refreshGrid();
hideSettings();
});
document.getElementById('ep-btn-export').addEventListener('click', () => {
const payload = JSON.stringify({ list: savedList, filters: chipFilters }, null, 2);
const blob = new Blob([payload], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `eporner-scraper-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
});
document.getElementById('ep-btn-import').addEventListener('click', () => {
document.getElementById('ep-import-file').click();
});
document.getElementById('ep-import-file').addEventListener('change', e => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = evt => {
try {
const data = JSON.parse(evt.target.result);
let imported = 0;
if (data.list && typeof data.list === 'object') {
Object.values(data.list).forEach(entry => {
if (!entry || !entry.id) return;
if (savedList[entry.id]) {
mergeEntry(savedList[entry.id], entry);
} else {
savedList[entry.id] = entry;
}
imported++;
});
}
if (Array.isArray(data.filters)) {
data.filters.filter(f => typeof f.value === 'string').forEach(f => {
if (!chipFilters.find(c => c.value === f.value)) chipFilters.push(f);
});
saveFilters();
renderChips();
}
saveList();
refreshGrid();
const btn = document.getElementById('ep-btn-import');
btn.textContent = `Imported ${imported}`;
setTimeout(() => { btn.textContent = 'Import'; }, 2000);
} catch {
alert('Invalid JSON file.');
}
e.target.value = '';
};
reader.readAsText(file);
});
renderChips();
refreshGrid();
}
// ─── Page scanning ────────────────────────────────────────────────────────
function mergeEntry(existing, fresh) {
const savedAt = existing.savedAt;
Object.assign(existing, fresh);
existing.savedAt = savedAt;
}
function scanPage() {
let changed = false;
let newCount = 0;
document.querySelectorAll('div.mb.hdy').forEach(cardEl => {
if (!cardHasTags(cardEl)) return;
const entry = extractEntry(cardEl);
if (!entry) return;
if (savedList[entry.id]) {
mergeEntry(savedList[entry.id], entry);
} else {
savedList[entry.id] = entry;
newCount++;
}
changed = true;
});
if (changed) {
saveList();
refreshGrid();
}
return newCount;
}
// ─── Auto-scan on page load ───────────────────────────────────────────────
function autoScan() {
const attempt = (tries = 0) => {
if (document.querySelectorAll('div.mb.hdy').length > 0) {
scanPage();
} else if (tries < 10) {
setTimeout(() => attempt(tries + 1), 800);
}
};
attempt();
}
// ─── MutationObserver for lazy-loaded cards ───────────────────────────────
function observeNewCards() {
let pending = false;
let dirty = false;
const flush = () => {
pending = false;
if (dirty) { dirty = false; saveList(); refreshGrid(); }
};
const observer = new MutationObserver(mutations => {
for (const { addedNodes } of mutations) {
for (const node of addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
const cards = node.classList?.contains('mb') && node.classList?.contains('hdy')
? [node]
: Array.from(node.querySelectorAll('div.mb.hdy'));
for (const card of cards) {
if (!cardHasTags(card)) continue;
const entry = extractEntry(card);
if (!entry) continue;
if (savedList[entry.id]) {
mergeEntry(savedList[entry.id], entry);
} else {
savedList[entry.id] = entry;
}
dirty = true;
}
}
}
if (dirty && !pending) {
pending = true;
setTimeout(flush, 1000);
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
// ─── Boot ─────────────────────────────────────────────────────────────────
function boot() {
createOverlay();
autoScan();
observeNewCards();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();