htfc786 / 国家中小学智慧教育平台 查看电子课本

// ==UserScript==
// @name         国家中小学智慧教育平台 查看电子课本
// @namespace    https://gist.github.com/htfc786/3b8a7b79c927ceefc6ebc1c56eb839ce
// @version      0.2
// @description  国家中小学智慧教育平台查看电子课本 by htfc786
// @author       htfc786
// @license      MIT
// @match        https://basic.smartedu.cn/tchMaterial/*
// @icon         https://basic.smartedu.cn/favicon.ico
// @grant        none
// @require      https://cdn.bootcdn.net/ajax/libs/crypto-js/4.2.0/crypto-js.min.js
// ==/UserScript==

// URL解析工具 (原f.parse)
function parseUrl(url) {
  const options = {
    strictMode: false,
    key: ["source", "protocol", "authority", "userInfo", "user", "password", "host", "port", "relative", "path", "directory", "file", "query", "anchor"],
    q: {
      name: "queryKey",
      parser: /(?:^|&)([^&=]*)=?([^&]*)/g
    },
    parser: {
      loose: /^(?:(?![^:@]+:[^:@\/]*@)([^:\/?#.]+):)?(?:\/\/)?((?:(([^:@]*)(?::([^:@]*))?)?@)?([^:\/?#]*)(?::(\d*))?)(((\/(?:[^?#](?![^?#\/]*\.[^?#\/.]+(?:[?#]|$)))*\/?)?([^?#\/]*))(?:\?([^#]*))?(?:#(.*))?)/
    }
  };

  const match = options.parser.loose.exec(url) || [];
  const result = {};

  options.key.forEach((key, index) => {
    result[key] = match[index] || "";
  });

  // 解析查询参数
  result.queryKey = {};
  result.query.replace(options.q.parser, (_, key, value) => {
    if (key) {
      if (!result.queryKey[key]) {
        result.queryKey[key] = value;
      } else if (Array.isArray(result.queryKey[key])) {
        result.queryKey[key].push(value);
      } else {
        result.queryKey[key] = [result.queryKey[key], value];
      }
    }
  });

  return result;
}

// 获取地址栏参数
function getQueryVariable(variable) {
    var urlInfo = parseUrl(window.location.href)
    return urlInfo.queryKey[variable] || null
}

// 生成随机字符串 (原Ze)
function generateRandomString(length = 8) {
  const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
  let result = "";
  for (let i = 0; i < length; i++) {
    result += charset.charAt(Math.floor(Math.random() * charset.length));
  }
  return result;
}

// 读取Token信息
function getToken() {
  try {
    const storedToken = localStorage.getItem("ND_UC_AUTH-e5649925-441d-4a53-b525-51a2f1c4e0a8&ncet-xedu&token");
    const value = JSON.parse(storedToken)?.value
    return storedToken ? JSON.parse(value) : null;
  } catch (e) {
    console.error("Token解析失败:", e);
    return null;
  }
}

// getAuthHeader
function getAuthHeader({url, method="GET", useToken} = {}) {
  if (!url) {
    console.error("UC-SDK: 缺少参数url");
    return "";
  }

  // 获取Token(需要实现存储逻辑)
  const token = useToken || getToken();
  if (!token) {
    console.warn("UC-SDK: 当前SDK实例未登录");
    return "";
  }

  // 生成认证头 (原He)
  function generateAuthHeader(url, method = "GET", { accessToken, macKey, diff }) {
    // 生成nonce的值
    const nonce = `${new Date().getTime() + parseInt(diff, 10)}:${generateRandomString(8)}`;
    // 生成MAC签名 (原ze)
    const parsedUrl = parseUrl(url);
    const normalizedMethod = method.toUpperCase();
    const signingData = `${nonce}\n${normalizedMethod}\n${parsedUrl.relative}\n${parsedUrl.authority}\n`;
    const macSignature = CryptoJS.HmacSHA256(signingData, macKey).toString(CryptoJS.enc.Base64);
    // 拼装
    return `MAC id="${accessToken}",nonce="${nonce}",mac="${macSignature}"`;
  }

  return generateAuthHeader(url, method, {
    accessToken: token.access_token,
    macKey: token.mac_key,
    diff: token.diff
  });
}

function getBookInfo(contentId) {
    return fetch(`https://s-file-2.ykt.cbern.com.cn/zxx/ndrv2/resources/tch_material/details/${contentId}.json`)
        .then(response => response.json())
        .then(data => {
            let pdfUrl = null;
            const title = data.title;

            const ti_items = data.ti_items;
            // 寻找Array中lc_ti_format为pdf的那一项
            const pdfItem = ti_items.find(item => item.lc_ti_format === "pdf");
            if (pdfItem) {
                pdfUrl = pdfItem.ti_storages[0]; // 返回pdfItem.ti_storages[0]的值
            }
            // 返回对象
            return { pdfUrl, title };
        })
}

// 加载样式文本
const buildLoadingCssText = `
  .extensionLoading {
    width: 100%;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    font-family: Arial, sans-serif;
  }
  .loading-progress-container {
    width: 80%;
    max-width: 600px;
    height: 20px;
    background-color: #e0e0e0;
    border-radius: 10px;
    overflow: hidden;
    margin: 20px 0;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  }
  .loading-progress-bar {
    height: 100%;
    background: linear-gradient(90deg, #4285f4, #0ea5e9);
    border-radius: 10px;
    width: 0%;
  }
  .loading-text {
    font-size: 18px;
    color: #333;
    margin-bottom: 10px;
  }
  .loading-percentage {
    font-size: 16px;
    color: #666;
  }
  .loading-spinner {
    width: 40px;
    height: 40px;
    border: 4px solid #f3f3f3;
    border-top: 4px solid #4285f4;
    border-radius: 50%;
    animation: spin 1s linear infinite;
    margin-bottom: 20px;
  }
  @keyframes spin {
    0% { transform: rotate(0deg); }
    100% { transform: rotate(360deg); }
  }
`;

// 构建加载DOM并处理进度
function buildLoadingDom(injectDom, downloadFetch) {
  injectDom.innerHTML = "";

  // 添加样式
  const style = document.createElement("style");
  style.innerHTML = buildLoadingCssText;
  document.head.appendChild(style);

  // 创建加载容器
  const extensionLoading = document.createElement("div");
  extensionLoading.className = "extensionLoading";

  // 加载动画
  const spinner = document.createElement("div");
  spinner.className = "loading-spinner";
  extensionLoading.appendChild(spinner);

  // 加载文本
  const loadingText = document.createElement("div");
  loadingText.className = "loading-text";
  loadingText.textContent = "正在加载电子课本...";
  extensionLoading.appendChild(loadingText);

  // 进度条容器
  const progressContainer = document.createElement("div");
  progressContainer.className = "loading-progress-container";
  extensionLoading.appendChild(progressContainer);

  // 进度条
  const progressBar = document.createElement("div");
  progressBar.className = "loading-progress-bar";
  progressContainer.appendChild(progressBar);

  // 百分比显示
  const percentageText = document.createElement("div");
  percentageText.className = "loading-percentage";
  percentageText.textContent = "0.00%";
  extensionLoading.appendChild(percentageText);

  injectDom.append(extensionLoading);

  // 处理下载进度
  return downloadFetch.then(response => {
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    // 获取文件总大小
    const contentLength = response.headers.get('Content-Length');
    const totalSize = contentLength ? parseInt(contentLength) : 0;
    let loadedSize = 0;

    // 创建读取器
    const reader = response.body.getReader();
    const chunks = [];

    // 读取数据并更新进度
    function read() {
      return reader.read().then(({ done, value }) => {
        if (done) {
          // 下载完成,进度设为100%
          progressBar.style.width = "100%";
          percentageText.textContent = "100.00%";
          return chunks;
        }

        chunks.push(value);
        loadedSize += value.length;

        // 计算进度百分比(保留两位小数)
        const progress = totalSize ? (loadedSize / totalSize) * 100 : 0;
        const progressFixed = progress.toFixed(2);

        // 更新进度条和文本
        progressBar.style.width = `${progressFixed}%`;
        percentageText.textContent = `${progressFixed}%`;

        return read();
      });
    }

    // 开始读取并拼接Blob
    return read().then(chunks => {
      const blob = new Blob(chunks, { type: 'application/pdf' });
      return blob;
    });
  });
}

const buildPdfDomCssText = `
  .extensionPdfContainer {
    padding: 0;
    position: relative;
    z-index: 1200;
    height: 100%;
  }
  .extensionPdfContainer > embed {
    height: 100%;
    width: 100%;
  }
  .extensionPdfContainer.big > embed {
    position: fixed;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    z-index: 100000;
  }
  .extensionPdfContainer > a {
    width: 60px;
    height: 20px;
    font-size: 12px;
    background-color: rgba(102,204,255,0.5);
    position: absolute;
    text-align: center;
    line-height: 20px;
    color: #fff;
    left: 0;
    top: 0;
    z-index: 100000;
  }
  .extensionPdfContainer.big > a {
    position: fixed;
    z-index: 1000000;
  }
  .extensionNewPageOpen {
    width: 70px;
    height: 60px;
    background-color: red;
    position: fixed;
    text-align: center;
    line-height: 30px;
    color: #fff;
    font-size: 20px;
    left: 0;
    bottom: 0;
    z-index: 1100;
  }
`;

function buildPdfDom(injectDom, pdfUrl) {
  injectDom.innerHTML = "";
  // style
  const style = document.createElement("style");
  style.innerHTML = buildPdfDomCssText;
  document.head.appendChild(style);

  // injectDom
  // .extensionPdfContainer
  const extensionPdfContainer = document.createElement("div");
  extensionPdfContainer.className = "extensionPdfContainer";

  // .extensionPdfContainer > embed
  const embed = document.createElement("embed");
  embed.id = "pdfWatch";
  embed.src = pdfUrl;
  extensionPdfContainer.appendChild(embed);

  // .extensionPdfContainer > a
  const pdfBig = document.createElement("a");
  pdfBig.id = "pdfBig";
  pdfBig.innerText = "网页全屏";
  pdfBig.addEventListener("click", ()=>{
    if(!extensionPdfContainer.classList.contains("big")){
      extensionPdfContainer.classList.add("big")
      pdfBig.innerText = "退出全屏"
    }else{
      extensionPdfContainer.classList.remove("big")
      pdfBig.innerText = "网页全屏"
    }
  });
  extensionPdfContainer.appendChild(pdfBig);

  injectDom.appendChild(extensionPdfContainer);

  // 新页面打开
  const extensionNewPageOpen = document.createElement("a");
  extensionNewPageOpen.className = "extensionNewPageOpen";
  extensionNewPageOpen.innerText = "新页面打开";
  extensionNewPageOpen.target = "_blank";
  extensionNewPageOpen.href = pdfUrl;
  document.body.appendChild(extensionNewPageOpen);

}

(async function() {
    'use strict';
    // Your code here...
    if (!getToken()) { // 没有登陆就不运行
        return;
    }

    var contentId = getQueryVariable("contentId");

    const { pdfUrl, title } = await getBookInfo(contentId)
    const authHeader = getAuthHeader({ url:pdfUrl }) // key: x-nd-auth
    console.log("pdfUrl: ", pdfUrl)
    console.log("authHeader: ", authHeader)
    // 生成curl和wget的下载链接
    const curl = `curl -H 'x-nd-auth: ${authHeader}' '${pdfUrl}' -o '${title}.pdf'`;
    const wget = `wget -O '${title}.pdf' --header='x-nd-auth: ${authHeader}' '${pdfUrl}'`;
    console.log("curl: ", curl)
    console.log("wget: ", wget)

    // 下载pdf
    const downloadFetch = fetch(pdfUrl, {
        headers: {
            'x-nd-auth': authHeader,
        }
    })

    function start(injectDom) {
        injectDom.innerHTML = ""
        buildLoadingDom(injectDom, downloadFetch)
          .then(blob => {
            console.log("Download Success! blob url:", blob)
            const url = URL.createObjectURL(blob, { type: 'application/pdf' });
            buildPdfDom(injectDom, url);
          })
    }

    let waitTime = 0;
    let wait = () => {
        if (waitTime>500){
            console.log("wait too many times!!!")
            console.log("exit")
            return;
        }
        const injectDom = document.querySelector(".document-context");
        if (injectDom) {
          start(injectDom)
          return;
        }
        waitTime++;
        setTimeout(wait, 100);
    }
    wait()
})();