PW Course Downloader

Download PW course content

当前为 2025-01-21 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         PW Course Downloader
// @namespace    KorigamiK
// @version      1.0.0
// @description  Download PW course content
// @author       KorigamiK
// @match        https://www.pw.live/*
// @grant        GM_addStyle
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.9.1/jszip.min.js
// @license MIT
// ==/UserScript==
// deno-lint-ignore-file no-window

const API = {
  BASE_URL: "https://api.penpencil.co",
  BATCH_ID: "671f543f10df7bb168d0296a",
  SESSION_TOKEN: /* Paste your session token here */ "Bearer ...",
  async fetch(endpoint) {
    const res = await fetch(`${this.BASE_URL}${endpoint}`, {
      headers: { Authorization: this.SESSION_TOKEN },
    });
    return res.json();
  },

  getBatch: (slug) => API.fetch(`/v3/batches/${slug}/details`),
  getContent: (batchSlug, subjectSlug, type = "videos") =>
    API.fetch(
      `/v2/batches/${batchSlug}/subject/${subjectSlug}/contents?contentType=${type}`,
    ),
  getTests: (subjectId) =>
    API.fetch(
      `/v3/test-service/tests/check-tests?testSource=BATCH_QUIZ&batchId=${API.BATCH_ID}&batchSubjectId=${subjectId}`,
    ),
  getDPPs: (subjectId) =>
    API.fetch(
      `/v3/test-service/tests/dpp?batchId=${API.BATCH_ID}&batchSubjectId=${subjectId}&isSubjective=false`,
    ),
};

const UI = {
  styles: `
      .pw-dl {
          position: fixed;
          bottom: 20px;
          right: 20px;
          background: #fff;
          border-radius: 8px;
          box-shadow: 0 2px 12px rgba(0,0,0,0.15);
          padding: 15px;
          z-index: 9999;
          width: 300px;
          font-family: system-ui;
      }
      .pw-dl select { width: 100%; margin: 8px 0; padding: 5px; }
      .pw-dl button {
          background: #4CAF50;
          color: white;
          border: none;
          padding: 8px 16px;
          border-radius: 4px;
          cursor: pointer;
          width: 100%;
      }
      .pw-dl .status {
          font-size: 14px;
          color: #666;
          margin-top: 8px;
      }
    `,

  create() {
    // Check if UI already exists
    if (document.querySelector(".pw-dl")) {
      return document.querySelector(".pw-dl");
    }

    GM_addStyle(this.styles);
    const container = document.createElement("div");
    container.className = "pw-dl";
    container.innerHTML = `
            <select multiple size="5"></select>
            <button class="download-selected">Download Selected</button>
            <div class="status"></div>
        `;
    document.body.appendChild(container);
    return container;
  },

  updateStatus(msg) {
    document.querySelector(".pw-dl .status").textContent = msg;
  },
};

class ContentDownloader {
  constructor(batchSlug) {
    if (window.pwDownloaderInstance) {
      return window.pwDownloaderInstance;
    }

    this.batchSlug = batchSlug;
    this.subjects = [];
    this.zip = new JSZip();
    this.init();

    window.pwDownloaderInstance = this;
  }

  async init() {
    const ui = UI.create();
    const select = ui.querySelector("select");

    // Add download all buttons
    ui.querySelector("button").insertAdjacentHTML(
      "beforebegin",
      `
            <div style="display: flex; gap: 8px; margin-bottom: 8px;">
                <button class="download-all">Download All Content</button>
                <button class="download-pdfs">Download All PDFs</button>
            </div>
        `,
    );

    try {
      const { data } = await API.getBatch(this.batchSlug);
      this.subjects = data.subjects;

      select.innerHTML = this.subjects
        .map((s) => `<option value="${s._id}">${s.subject}</option>`)
        .join("");

      ui.querySelector(".download-selected").addEventListener(
        "click",
        () => this.downloadSelected(select),
      );
      ui.querySelector(".download-all").addEventListener(
        "click",
        () => this.downloadAll(),
      );
      ui.querySelector(".download-pdfs").addEventListener(
        "click",
        () => this.downloadAllPDFs(),
      );
    } catch (err) {
      UI.updateStatus("Failed to load subjects");
      console.error(err);
    }
  }

  async downloadSelected(select) {
    const selectedOptions = [...select.selectedOptions];

    if (selectedOptions.length === 0) {
      UI.updateStatus("Please select subjects");
      return;
    }

    const selectedSubjects = selectedOptions
      .map((option) => this.subjects.find((s) => s._id === option.value))
      .filter(Boolean);

    UI.updateStatus(`Processing ${selectedSubjects.length} subjects...`);

    try {
      this.zip = new JSZip();
      const rootFolder = this.zip.folder("PW Selected Subjects");
      let totalPdfs = 0;
      let processedPdfs = 0;
      let totalSize = 0;

      for (const [index, subject] of selectedSubjects.entries()) {
        try {
          UI.updateStatus(
            `Processing ${subject.subject} (${
              index + 1
            }/${selectedSubjects.length})...`,
          );

          const subjectFolder = rootFolder.folder(
            this.sanitizeFileName(subject.subject),
          );
          const content = await this.getSubjectContent(subject);

          // Add content JSON
          const contentJson = JSON.stringify(content, null, 2);
          subjectFolder.file("content.json", contentJson);
          totalSize += contentJson.length;

          // Process PDFs
          if (content.notes?.length) {
            const pdfFolder = subjectFolder.folder("PDFs");
            const pdfs = this.extractPdfLinks(content.notes);
            totalPdfs += pdfs.length;

            if (pdfs.length) {
              const pdfSizes = await this.processPdfs(
                pdfs,
                pdfFolder,
                (processed) => {
                  processedPdfs += processed;
                  UI.updateStatus(
                    `Downloaded ${processedPdfs}/${totalPdfs} PDFs...`,
                  );
                },
              );
              totalSize += pdfSizes.reduce((a, b) => a + b, 0);
            }
          }

          // Check total size
          if (totalSize > 1.5 * 1024 * 1024 * 1024) { // 1.5GB limit
            throw new Error(
              "Content size too large. Try selecting fewer subjects.",
            );
          }
        } catch (error) {
          console.error(`Error processing subject ${subject.subject}:`, error);
          UI.updateStatus(
            `Warning: Some content for ${subject.subject} might be missing`,
          );
          await new Promise((resolve) => setTimeout(resolve, 1000));
        }
      }

      UI.updateStatus("Creating ZIP file... This might take a moment.");

      try {
        const zipBlob = await this.generateZipBlob();
        if (!this.isValidBlob(zipBlob)) {
          throw new Error("Invalid ZIP file generated");
        }

        await this.downloadZip(zipBlob);
        UI.updateStatus("Download complete!");
      } catch (error) {
        throw new Error(`ZIP creation failed: ${error.message}`);
      }
    } catch (error) {
      console.error("Download error:", error);
      UI.updateStatus(
        `Error: ${error.message}. Try downloading fewer subjects.`,
      );
    } finally {
      this.zip = new JSZip();
    }
  }

  async generateZipBlob() {
    return await this.zip.generateAsync({
        type: "blob",
        compression: "DEFLATE",
        compressionOptions: { level: 6 },
      })
  }

  isValidBlob(blob) {
    return blob && blob instanceof Blob && blob.size > 0;
  }

  async processPdfs(pdfs, folder, progressCallback) {
    const chunks = this.chunkArray(pdfs, 3);
    let processedCount = 0;
    const sizes = [];

    for (const chunk of chunks) {
      const chunkPromises = chunk.map(async (pdf) => {
        try {
          const response = await fetch(pdf.url);
          if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
          }
          const blob = await response.blob();
          folder.file(this.sanitizeFileName(pdf.filename), blob);
          sizes.push(blob.size);
          return true;
        } catch (error) {
          console.error(`Failed to download PDF: ${pdf.filename}`, error);
          return false;
        }
      });

      const results = await Promise.allSettled(chunkPromises);
      const successCount = results.filter((r) =>
        r.status === "fulfilled" && r.value
      ).length;
      processedCount += successCount;
      progressCallback(successCount);

      await new Promise((resolve) => setTimeout(resolve, 500));
    }

    return sizes;
  }

  downloadZip(blob) {
    return new Promise((resolve, reject) => {
      try {
        if (!this.isValidBlob(blob)) {
          reject(new Error("Invalid ZIP blob"));
          return;
        }

        const url = URL.createObjectURL(blob);
        const a = document.createElement("a");
        a.href = url;
        const timestamp = new Date().toISOString().split("T")[0];
        a.download = `pw_selected_subjects_${timestamp}.zip`;

        a.onclick = () => {
          setTimeout(() => {
            URL.revokeObjectURL(url);
            resolve();
          }, 1000);
        };

        a.click();
      } catch (error) {
        reject(error);
      }
    });
  }

  extractPdfLinks(notes) {
    const pdfs = [];
    notes.forEach((note) => {
      note.homeworkIds?.forEach((homework) => {
        homework.attachmentIds?.forEach((attachment) => {
          pdfs.push({
            topic: homework.topic,
            url: `${attachment.baseUrl}${attachment.key}`,
            filename: attachment.name,
          });
        });
      });
    });
    return pdfs;
  }

  async downloadAll() {
    UI.updateStatus("Downloading all content...");
    const content = {};

    for (const subject of this.subjects) {
      UI.updateStatus(`Downloading ${subject.subject}...`);
      content[subject.subject] = await this.getSubjectContent(subject);
    }

    this.saveToFile(content, "all_content");
    UI.updateStatus("All content downloaded!");
  }

  async downloadAllPDFs() {
    UI.updateStatus("Gathering PDF links...");
    const pdfs = [];

    // Create main folder in zip
    const rootFolder = this.zip.folder("PW Course PDFs");

    for (const subject of this.subjects) {
      UI.updateStatus(`Processing ${subject.subject}...`);
      const { notes } = await this.getSubjectContent(subject);

      // Create subject folder
      const subjectFolder = rootFolder.folder(
        this.sanitizeFileName(subject.subject),
      );

      notes.forEach((note) => {
        note.homeworkIds?.forEach((homework) => {
          homework.attachmentIds?.forEach((attachment) => {
            pdfs.push({
              subject: subject.subject,
              topic: homework.topic,
              url: `${attachment.baseUrl}${attachment.key}`,
              filename: attachment.name,
              folder: subjectFolder,
            });
          });
        });
      });
    }

    if (pdfs.length === 0) {
      UI.updateStatus("No PDFs found!");
      return;
    }

    UI.updateStatus(`Found ${pdfs.length} PDFs. Starting download...`);

    try {
      await this.downloadAndZipPDFs(pdfs);
    } catch (error) {
      console.error("Error creating ZIP:", error);
      UI.updateStatus("Error creating ZIP file!");
    }
  }

  async downloadAndZipPDFs(pdfs) {
    let completed = 0;

    // Download all PDFs concurrently but with rate limiting
    const chunks = this.chunkArray(pdfs, 5); // Process 5 PDFs at a time

    for (const chunk of chunks) {
      await Promise.all(chunk.map(async (pdf) => {
        try {
          const response = await fetch(pdf.url);
          const blob = await response.blob();

          // Add to appropriate folder in zip
          const safeName = this.sanitizeFileName(pdf.filename);
          pdf.folder.file(safeName, blob);

          completed++;
          UI.updateStatus(`Processing PDFs: ${completed}/${pdfs.length}`);
        } catch (error) {
          console.error(`Failed to process ${pdf.filename}:`, error);
        }
      }));
    }

    UI.updateStatus("Creating ZIP file...");

    // Generate and download ZIP
    const zipBlob = await this.zip.generateAsync({
      type: "blob",
      compression: "DEFLATE",
      compressionOptions: {
        level: 6,
      },
    });

    const url = URL.createObjectURL(zipBlob);
    const a = document.createElement("a");
    a.href = url;
    a.download = `pw_course_pdfs_${new Date().toISOString().split("T")[0]}.zip`;
    a.click();
    URL.revokeObjectURL(url);

    // Reset zip for next use
    this.zip = new JSZip();

    UI.updateStatus("All PDFs downloaded in ZIP!");
  }

  // Utility methods
  sanitizeFileName(filename) {
    return filename.replace(/[/\\?%*:|"<>]/g, "-");
  }

  chunkArray(array, size) {
    const chunks = [];
    for (let i = 0; i < array.length; i += size) {
      chunks.push(array.slice(i, i + size));
    }
    return chunks;
  }

  async getSubjectContent(subject) {
    const [videos, notes, tests, dpps] = await Promise.all([
      API.getContent(this.batchSlug, subject.slug, "videos"),
      API.getContent(this.batchSlug, subject.slug, "notes"),
      API.getTests(subject._id),
      API.getDPPs(subject._id),
    ]);

    return {
      videos: videos.data.map((v) => ({
        title: v.topic,
        url: v.url,
        date: v.date,
        duration: v.videoDetails?.duration,
        teacher: v.teachers?.[0]
          ? `${v.teachers[0].firstName} ${v.teachers[0].lastName}`.trim()
          : "Unknown Teacher",
      })),
      notes: notes.data,
      tests: tests.data,
      dpps: dpps.data,
    };
  }

  saveToFile(data, prefix = "content") {
    const blob = new Blob([JSON.stringify(data, null, 2)], {
      type: "application/json",
    });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = `pw_${prefix}_${new Date().toISOString().split("T")[0]}.json`;
    a.click();
    URL.revokeObjectURL(url);
  }
}

// Initialize only if not already initialized
if (!window.pwDownloaderInstance) {
  new ContentDownloader(
    "crash-course-gate-2025-computer-science-and-it-200846",
  );
}