知乎历史记录助手

电脑浏览器访问知乎时,没有历史记录功能,故本脚本模拟实现知乎历史记录功能。

当前为 2022-04-22 提交的版本,查看 最新版本

// ==UserScript==
// @name         知乎历史记录助手
// @namespace    [email protected]
// @version      1.0
// @description  电脑浏览器访问知乎时,没有历史记录功能,故本脚本模拟实现知乎历史记录功能。
// @author       QJX
// @match        https://*.zhihu.com/*
// @exclude      https://video.zhihu.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=zhihu.com
// @grant        none
// @license        GPLv3 License
// ==/UserScript==

(function() {
    'use strict';

    if (window.parent != window) return; // 仅在顶层窗口执行脚本,内嵌窗口不执行该脚本

    // 全局常量
    const historyTag = '_history_zhihu_by_QJX_';
    const configurations = {
        maxRecordsCount: 100, // 历史记录最大记录数目
        maxBriefContentCharacterCount: 100, // 最大简要内容文字个数
    };

    function getAncestorElementByClassName(element, className){ //  根据类名找祖先
        for (; element && !element.classList.contains(className); element = element.parentElement) {}
        return element;
    }

    function getHistory() { // 获取历史记录
        return JSON.parse(window.localStorage.getItem(historyTag) || '[]');
    }

    function saveHistory(history) { // 保存历史记录
        if (history.length > configurations.maxRecordsCount) history.length = configurations.maxRecordsCount;
        window.localStorage.setItem(historyTag, JSON.stringify(history));
    }

    function Record({type = '', title = '', url = '', authorName = '', content = '', imgURL = ''} = {}) { // 记录
        this.type = type; // 'question'|'answer'|'article'
        this.title = title;
        this.url = url;
        // 以下两项仅在type为answer或article时有效
        this.authorName = authorName;
        this.content = content;
        // 以下项仅在type为answer或article且内容中有图片时有效
        this.imgURL = imgURL;
    }

    function addRecord(history, record) { // 添加一条记录到历史记录中
        history.unshift(record);
    }

    function injectHistoryElement() { // 将历史记录按钮插入到HTML文档中
        let element = document.createElement('button');
        element.innerHTML = 'H';
        element.style.setProperty('width', '50px');
        element.style.setProperty('height', '50px');
        element.style.setProperty('color', '#FFF');
        element.style.setProperty('background-color', '#0066FF');
        element.style.setProperty('border-radius', '50%');
        element.style.setProperty('position', 'fixed');
        element.style.setProperty('right', '50px');
        element.style.setProperty('bottom', '50px');
        element.style.setProperty('text-align', 'center');
        element.addEventListener('click', () => openHistoryWindow());
        document.body.append(element);
    }

    function openHistoryWindow() { // 打开历史记录窗口
        let historyWindow = window.open('', '_blank');
        historyWindow.document.open();
        historyWindow.document.write(`
          <!DOCTYPE html>
          <html>
            <head>
              <meta charset="UTF-8">
              <title>知乎历史记录</title>
              <style>
                html {
                  font-size: 16px;
                }

                body {
                  background-color: #f7f7f7;
                }

                .link, .link:hover, .link:visited, .link:active {
                  position: relative;
                  width: 70%;
                  display: block;
                  margin: .5rem auto;
                  border: 1px solid black;
                  border-radius: 4px;
                  padding: .5rem;
                  color: #000;
                  background-color: #fff;
                  text-decoration:none;
                }

                .title {
                  margin: .5rem;
                  font-size: 1.2rem;
                  font-weight: 650;
                  text-overflow: ellipsis;
                  white-space: nowrap;
                  overflow: hidden;
                }

                .answer {
                  margin: .5rem;
                  text-overflow: ellipsis;
                  white-space: nowrap;
                  overflow: hidden;
                }

                .author {
                  font-weight: 550;
                }

                img {
                  float: right;
                  width: 8rem;
                  height: 4rem;;
                  object-fit: cover;
                }
              </style>
            </head>
            <body>
            </body>
          </html>
        `);
        historyWindow.document.close();

        const history = getHistory();
        let fragment = new DocumentFragment();
        history.forEach(h => {
            /**
             *  <a href=${url} class="link">
             *    <img src="${imgURL}">
             *    <p class="title">${title}</p>
             *    <p class="answer"><span class="author">${authorName}: </span>${content}</p>
             *  </a>
             */
            const a = historyWindow.document.createElement('a');
            const titleParagraph = historyWindow.document.createElement('p');
            a.setAttribute('href', h.url);
            a.classList .add('link');
            titleParagraph.classList.add('title');
            titleParagraph.innerHTML = h.title;
            if (h.imgURL) {
                    const imageElement = historyWindow.document.createElement('img');
                    imageElement.setAttribute('src', h.imgURL);
                    a.append(imageElement);
            }
            a.append(titleParagraph);
            if (['answer', 'article'].includes(h.type)) {
                const answerParagraph = historyWindow.document.createElement('p');
                const authorElement = historyWindow.document.createElement('span');
                answerParagraph.classList.add('answer');
                authorElement.classList.add('author');
                authorElement.innerHTML = `${h.authorName}: `;
                answerParagraph.append(authorElement);
                answerParagraph.append(h.content);
                a.append(answerParagraph);
            }
            fragment.append(a);
        });
        historyWindow.document.body.append(fragment);
    }

    /**
     * 有两种改变浏览记录的方式:
     *    1. 查看当前页面的URL,URL为以下三种模式改变浏览记录:
     *      1.1 路径为"/question/<number question>",表示某个问题
     *      1.2 路径为"/question/<number question>/answer/<number answer>",表示某个问题下的某个回答,主要信息:
     *          <div class="AuthorInfo">
     *            <meta itemprop="name" content="<author name>">
     *            <meta itemprop="image" content="<author image url>">
     *          </div>
     *      1.3 路径为"/p/<number article>",表示某篇专栏文章
     *    2. 监听用户点击"阅读全文",主要查看以下标签改变浏览记录:
     *          <div class="ContentItem" data-zop="{"authorName":<author name>,"title":<title>,"type":<type="answer"|"article">}">
     *            <meta itemprop="url" content="//www.zhihu.com/p/<number article>">
     *            <div class="RichContent">
     *              <div class="RichContent-inner">
     *              </div>
     *            </div>
     *          </div>
     */

    let history = getHistory();

    const questionPattern = /^\/question\/\d+$/;
    const answerPattern = /^\/question\/\d+\/answer\/\d+$/;
    const articlePattern = /^\/p\/\d+$/;

    const path = window.location.pathname;
    if (questionPattern.test(path)) {
        let record = new Record();
        record.type = 'question';
        record.title = document.querySelector('.QuestionHeader-title')?.textContent ?? '';
        record.url = location.href;
        addRecord(history, record);
        saveHistory(history);
    } else if (answerPattern.test(path)) {
        let record = new Record();
        record.type = 'answer';
        const contentItem = document.querySelector('.ContentItem');
        const info = JSON.parse(contentItem.dataset.zop);
        record.authorName = info.authorName;
        record.title = info.title;
        const text = contentItem.querySelector('.RichContent')?.textContent ?? '';
        record.content = text.length < configurations.maxBriefContentCharacterCount ? text : text.slice(0, configurations.maxBriefContentCharacterCount);
        record.url = location.href;
        const imgURL = contentItem.querySelector('.RichContent-inner figure > img')?.getAttribute('data-actualsrc') ?? '';
        record.imgURL = imgURL;
        addRecord(history, record);
        saveHistory(history);
    } else if (articlePattern.test(path)) {
        let record = new Record();
        record.type = 'article';
        const postContent = document.querySelector('.Post-content');
        const info = JSON.parse(postContent.dataset.zop);
        record.authorName = info.authorName;
        record.title = info.title;
        const text = document.querySelector('.Post-RichText')?.textContent ?? '';
        record.content = text.length < configurations.maxBriefContentCharacterCount ? text : text.slice(0, configurations.maxBriefContentCharacterCount);
        record.url = location.href;
        addRecord(history, record);
        saveHistory(history);
    }

    // 监听用户点击"阅读原文"
    document.body.addEventListener('click', e => {
        if (e.target.classList.contains('ContentItem-more') || // 点击"阅读原文"按钮
            e.target.classList.contains('RichContent-inner')) { // 点击收缩起来的文本
            const contentItem = getAncestorElementByClassName(e.target, 'ContentItem');
            const info = JSON.parse(contentItem.dataset.zop);
            let record = new Record();
            record.type = info.type;
            record.authorName = info.authorName;
            record.title = info.title;
            const meta = contentItem.querySelector(':scope > meta[itemprop="url"]');
            record.url = meta.getAttribute('content');
            let text = contentItem.querySelector('.RichContent-inner')?.textContent ?? '';
            // WEIRD:
            const index = text.indexOf(': ');
            const begin = index === -1 ? 0 : (index + 2);
            record.content = text.length < configurations.maxBriefContentCharacterCount ? text.slice(begin) : text.slice(begin, begin + configurations.maxBriefContentCharacterCount);
            addRecord(history, record);
            saveHistory(history);
        }
    });

    injectHistoryElement();
})();