浏览增强:自动标记已读帖子,按创作者分组查看浏览历史。 Adds "Viewed" tags to visited posts on pawchive.pw, with mark-as-unread support and artist-grouped history.
// ==UserScript==
// @name Better Pawchive
// @namespace https://github.com/better-pawchive
// @version 1.1.0
// @description 浏览增强:自动标记已读帖子,按创作者分组查看浏览历史。 Adds "Viewed" tags to visited posts on pawchive.pw, with mark-as-unread support and artist-grouped history.
// @author Codex
// @match https://pawchive.pw/*
// @match https://pawchive.st/*
// @match https://*.pawchive.pw/*
// @license MIT
// @grant none
// @run-at document-end
// ==/UserScript==
(function () {
"use strict";
var STORAGE_KEY = "better_pawchive";
function loadStore() {
try {
var raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { artists: {}, posts: {} };
return JSON.parse(raw);
} catch (e) {
return { artists: {}, posts: {} };
}
}
function saveStore(store) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); } catch (e) {}
}
var store = loadStore();
function markPostViewed(postId, service, userId, title) {
store.posts[postId] = {
id: postId, service: service, userId: userId,
title: title || "", viewedAt: new Date().toISOString()
};
saveStore(store);
}
function markPostUnread(postId) {
delete store.posts[postId];
saveStore(store);
}
function isPostViewed(postId) {
return postId in store.posts;
}
function trackArtist(artistId, service, name) {
if (!store.artists[artistId]) {
store.artists[artistId] = { id: artistId, service: service, name: name || "", lastSeenAt: new Date().toISOString() };
} else {
store.artists[artistId].lastSeenAt = new Date().toISOString();
if (name) store.artists[artistId].name = name;
}
saveStore(store);
}
var ARTIST_RE = /^\/([a-z0-9_]+)\/user\/([^/]+)\/?$/;
var POST_RE = /^\/([a-z0-9_]+)\/user\/([^/]+)\/post\/([^/]+)\/?$/;
function parseUrl(pathname) {
var m = pathname.match(POST_RE);
if (m) return { type: "post", service: m[1], userId: m[2], postId: m[3] };
m = pathname.match(ARTIST_RE);
if (m) return { type: "artist", service: m[1], userId: m[2] };
return { type: "other" };
}
function getCurrentPageInfo() {
var p = parseUrl(window.location.pathname);
if (p.type === "post") {
var titleEl = document.querySelector(".post__title");
var title = titleEl ? titleEl.textContent.trim().substring(0, 200) : "";
var nameEl = document.querySelector(".post__user-name");
var artistName = nameEl ? nameEl.textContent.trim() : "";
trackArtist(p.userId, p.service, artistName);
markPostViewed(p.postId, p.service, p.userId, title);
}
if (p.type === "artist") {
var nameEl = document.querySelector(".user-header__name span[itemprop='name']");
var artistName = nameEl ? nameEl.textContent.trim() : "";
trackArtist(p.userId, p.service, artistName);
}
}
function createViewedTag(postId) {
var container = document.createElement("span");
container.className = "bp-viewed-container";
container.style.cssText = "display:inline-flex;align-items:center;gap:4px;margin-left:6px;";
var label = document.createElement("span");
label.className = "bp-viewed-tag";
label.textContent = "viewed";
label.style.cssText = "color:#b4ffb4;font-size:0.75em;font-weight:600;";
var unreadBtn = document.createElement("button");
unreadBtn.className = "bp-unread-btn";
unreadBtn.innerHTML = "✕";
unreadBtn.title = "Mark as Unread";
unreadBtn.style.cssText =
"background:none;border:1px solid #b4ffb4;color:#b4ffb4;font-size:0.65em;" +
"line-height:1;cursor:pointer;border-radius:3px;padding:1px 4px;";
unreadBtn.addEventListener("click", function(e) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
markPostUnread(postId);
container.remove();
});
container.addEventListener("click", function(e) {
e.preventDefault();
e.stopPropagation();
});
container.appendChild(label);
container.appendChild(unreadBtn);
return container;
}
function addViewedTags() {
document.querySelectorAll("article.post-card").forEach(function(card) {
var postId = card.getAttribute("data-id");
if (!postId || !isPostViewed(postId)) return;
if (card.querySelector(".bp-viewed-container")) return;
var footer = card.querySelector(".post-card__footer > div");
if (!footer) return;
footer.appendChild(createViewedTag(postId));
});
var postSection = document.querySelector(".site-section--post");
if (postSection) {
var postId = postSection.getAttribute("data-id");
if (postId && isPostViewed(postId)) {
var postInfo = document.querySelector(".post__info");
if (postInfo && !postInfo.querySelector(".bp-viewed-container")) {
var tag = createViewedTag(postId);
tag.style.marginTop = "4px";
postInfo.appendChild(tag);
}
}
}
}
function injectStatsWidget() {
var sidebar = document.querySelector(".global-sidebar");
if (!sidebar) return;
var existing = document.getElementById("bp-stats-widget");
if (existing) existing.remove();
var postCount = Object.keys(store.posts).length;
var artistCount = Object.keys(store.artists).length;
var widget = document.createElement("div");
widget.id = "bp-stats-widget";
widget.className = "global-sidebar-entry";
widget.innerHTML =
'<div class="global-sidebar-entry-item header" style="display:flex;align-items:center;gap:6px;">' +
'<span style="font-size:0.85em;color:#b4ffb4;">✓</span>' +
'<span>Better Pawchive</span></div>' +
'<div class="global-sidebar-entry-item" style="font-size:0.8em;padding-left:28px;color:#aaa;">' +
artistCount + " creators · " + postCount + " posts</div>" +
'<a id="bp-mgmt-link" class="global-sidebar-entry-item" href="#" style="font-size:0.8em;padding-left:28px;color:#666;cursor:pointer;">View History</a>';
var accountEntry = sidebar.querySelector(".global-sidebar-entry.account");
if (accountEntry) {
sidebar.insertBefore(widget, accountEntry);
} else {
sidebar.appendChild(widget);
}
document.getElementById("bp-mgmt-link").addEventListener("click", function(e) {
e.preventDefault();
showManagementOverlay();
});
}
// --- View History overlay (grouped by artist) ---
function showManagementOverlay() {
var existing = document.getElementById("bp-overlay");
if (existing) { existing.remove(); return; }
var posts = Object.values(store.posts).sort(function(a, b) {
return new Date(b.viewedAt) - new Date(a.viewedAt);
});
// Group by service:userId
var groups = {};
var groupOrder = [];
posts.forEach(function(p) {
var key = p.service + ":" + p.userId;
if (!groups[key]) {
groups[key] = [];
groupOrder.push(key);
}
groups[key].push(p);
});
function getArtistName(service, userId) {
var a = store.artists[userId];
if (a && a.name) return a.name;
return userId;
}
var bodyHtml = "";
if (groupOrder.length === 0) {
bodyHtml = '<div style="text-align:center;color:#888;padding:20px;">No viewed posts yet.</div>';
} else {
groupOrder.forEach(function(key) {
var parts = key.split(":");
var service = parts[0];
var userId = parts[1];
var groupPosts = groups[key];
var artistName = esc(getArtistName(service, userId));
var groupId = "bp-group-" + key.replace(/[^a-z0-9]/g, "-");
bodyHtml +=
'<div class="bp-artist-group" style="margin-bottom:2px;">' +
'<div class="bp-group-header" data-group="' + groupId + '" style="display:flex;align-items:center;justify-content:space-between;' +
'background:#252540;border-radius:4px;padding:8px 12px;cursor:pointer;user-select:none;' +
'border-left:3px solid #b4ffb4;">' +
'<div style="display:flex;align-items:center;gap:8px;">' +
'<span class="bp-group-arrow" style="color:#b4ffb4;font-size:0.8em;transition:transform 0.15s;">\u25B6</span>' +
'<a href="/' + esc(service) + '/user/' + esc(userId) + '" style="color:#ddd;font-weight:600;text-decoration:none;">' + artistName + '</a>' +
'<span style="color:#888;font-size:0.75em;background:#333;border-radius:999px;padding:1px 8px;">' + groupPosts.length + '</span>' +
'</div>' +
'<div style="display:flex;gap:6px;">' +
'<button class="bp-clear-group" data-key="' + esc(key) + '" style="background:none;border:1px solid #555;color:#888;border-radius:4px;padding:2px 8px;cursor:pointer;font-size:0.75em;">clear</button>' +
'</div>' +
'</div>' +
'<div class="bp-group-body" id="' + groupId + '" style="display:none;">';
groupPosts.forEach(function(p) {
var link = "/" + p.service + "/user/" + p.userId + "/post/" + p.id;
var title = p.title || "(untitled)";
var date = p.viewedAt ? new Date(p.viewedAt).toLocaleString() : "";
bodyHtml +=
'<div style="display:flex;align-items:center;justify-content:space-between;' +
'padding:5px 12px 5px 36px;border-bottom:1px solid #1a1a2e;font-size:0.9em;">' +
'<a href="' + link + '" style="color:#b4ffb4;max-width:60%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + esc(title) + '</a>' +
'<span style="color:#666;font-size:0.8em;">' + date + '</span>' +
'<button class="bp-del-btn" data-id="' + p.id + '" style="background:none;border:none;color:#f66;cursor:pointer;font-size:0.95em;">\u2715</button>' +
'</div>';
});
bodyHtml += '</div></div>';
});
}
var overlay = document.createElement("div");
overlay.id = "bp-overlay";
overlay.style.cssText =
"position:fixed;top:0;left:0;width:100%;height:100%;z-index:99999;" +
"background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;";
overlay.innerHTML =
'<div style="background:#1a1a2e;border:1px solid #333;border-radius:8px;max-width:780px;width:93%;max-height:84vh;' +
'display:flex;flex-direction:column;">' +
'<div style="display:flex;justify-content:space-between;align-items:center;padding:16px 20px;' +
'border-bottom:1px solid #333;flex-shrink:0;">' +
'<h3 style="margin:0;color:#b4ffb4;">View History</h3>' +
'<div style="display:flex;align-items:center;gap:8px;">' +
'<button id="bp-expand-all" style="background:none;border:1px solid #555;color:#888;border-radius:4px;padding:3px 10px;cursor:pointer;font-size:0.8em;">Expand All</button>' +
'<button id="bp-collapse-all" style="background:none;border:1px solid #555;color:#888;border-radius:4px;padding:3px 10px;cursor:pointer;font-size:0.8em;">Collapse All</button>' +
'<span style="color:#888;font-size:0.85em;">' + Object.keys(store.posts).length + ' posts · ' + groupOrder.length + ' creators</span>' +
'<button id="bp-clear-all" style="margin-left:4px;background:#a00;color:#fff;border:none;border-radius:4px;padding:4px 10px;cursor:pointer;">Clear All</button>' +
'<button id="bp-close-overlay" style="background:#333;color:#fff;border:none;border-radius:4px;padding:4px 12px;cursor:pointer;">Close</button>' +
'</div></div>' +
'<div style="overflow:auto;padding:12px 20px 20px;">' +
bodyHtml +
'</div></div>';
document.body.appendChild(overlay);
overlay.addEventListener("click", function(e) { if (e.target === overlay) overlay.remove(); });
overlay.querySelector("#bp-close-overlay").addEventListener("click", function() { overlay.remove(); });
overlay.querySelector("#bp-clear-all").addEventListener("click", function() {
if (confirm("Clear all viewed history? This cannot be undone.")) {
store.posts = {};
store.artists = {};
saveStore(store);
overlay.remove();
injectStatsWidget();
addViewedTags();
}
});
// Toggle group open/close
overlay.querySelectorAll(".bp-group-header").forEach(function(header) {
header.addEventListener("click", function(e) {
if (e.target.tagName === "A" || e.target.tagName === "BUTTON") return;
var groupId = header.getAttribute("data-group");
var body = document.getElementById(groupId);
if (!body) return;
var arrow = header.querySelector(".bp-group-arrow");
if (body.style.display === "none") {
body.style.display = "block";
arrow.textContent = "\u25BC";
} else {
body.style.display = "none";
arrow.textContent = "\u25B6";
}
});
});
function setAllGroups(open) {
overlay.querySelectorAll(".bp-group-body").forEach(function(body) {
body.style.display = open ? "block" : "none";
});
overlay.querySelectorAll(".bp-group-arrow").forEach(function(arrow) {
arrow.textContent = open ? "\u25BC" : "\u25B6";
});
}
overlay.querySelector("#bp-expand-all").addEventListener("click", function() { setAllGroups(true); });
overlay.querySelector("#bp-collapse-all").addEventListener("click", function() { setAllGroups(false); });
overlay.querySelectorAll(".bp-del-btn").forEach(function(btn) {
btn.addEventListener("click", function() {
markPostUnread(btn.getAttribute("data-id"));
overlay.remove();
injectStatsWidget();
addViewedTags();
});
});
overlay.querySelectorAll(".bp-clear-group").forEach(function(btn) {
btn.addEventListener("click", function(e) {
e.stopPropagation();
var key = btn.getAttribute("data-key");
var targets = groups[key];
if (!targets) return;
var artistName = getArtistName(targets[0].service, targets[0].userId);
if (!confirm("Remove all " + targets.length + " viewed posts from \"" + artistName + "\"?")) return;
targets.forEach(function(p) { delete store.posts[p.id]; });
saveStore(store);
overlay.remove();
injectStatsWidget();
addViewedTags();
});
});
}
function esc(str) {
return str.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
}
var observer;
function startObserver() {
if (observer) observer.disconnect();
observer = new MutationObserver(function() {
getCurrentPageInfo();
addViewedTags();
});
var target = document.getElementById("main") || document.body;
observer.observe(target, { childList: true, subtree: true });
}
function init() {
getCurrentPageInfo();
addViewedTags();
injectStatsWidget();
startObserver();
console.log("[BetterPawchive] Ready. Tracked:", Object.keys(store.posts).length, "posts");
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
document.body.addEventListener("htmx:afterSwap", function() {
setTimeout(function() {
getCurrentPageInfo();
addViewedTags();
injectStatsWidget();
}, 150);
});
})();