PW Course Downloader

Download PW course content

La data de 21-01-2025. Vezi ultima versiune.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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