Better Pawchive

浏览增强:自动标记已读帖子,按创作者分组查看浏览历史。 Adds "Viewed" tags to visited posts on pawchive.pw, with mark-as-unread support and artist-grouped history.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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;">&#x2713;</span>' +
      '<span>Better Pawchive</span></div>' +
      '<div class="global-sidebar-entry-item" style="font-size:0.8em;padding-left:28px;color:#aaa;">' +
      artistCount + " creators &middot; " + 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 &middot; ' + 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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
  }

  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);
  });

})();