PW Course Downloader

Download PW course content

// ==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",
  );
}