MasteringPhysics Extractor

Extract problems from MasteringPhysics (single or full assignment) with progress bar and save as JSON

  1. // ==UserScript==
  2. // @name MasteringPhysics Extractor
  3. // @namespace http://tampermonkey.net/
  4. // @version 3.0.0
  5. // @description Extract problems from MasteringPhysics (single or full assignment) with progress bar and save as JSON
  6. // @author You & AI Assistant
  7. // @match https://session.physics-mastering.pearson.com/myct/itemView*
  8. // @grant GM_setValue
  9. // @grant GM_getValue
  10. // @grant GM_deleteValue
  11. // @grant GM_registerMenuCommand
  12. // @grant unsafeWindow
  13. // @license MIT
  14. // ==/UserScript==
  15.  
  16. /**
  17. * MasteringPhysics Extractor - Extract problems into a JSON file
  18. *
  19. * Features:
  20. * - Extract single problems or entire assignments
  21. * - Properly handles MathJax and scientific notation
  22. * - Preserves figures and complex question types (MCQ, ranking)
  23. * - Includes progress tracking with cancellation option
  24. * - Robust error handling and navigation
  25. * - User-configurable options
  26. *
  27. * @version 3.0.0
  28. */
  29. (function() {
  30. 'use strict';
  31.  
  32. // --- Configuration ---
  33. const CONFIG = {
  34. // Data storage keys
  35. STORAGE_KEY: 'masteringPhysicsExtractionData',
  36. EXTRACTION_IN_PROGRESS_KEY: 'masteringPhysicsExtractionInProgress',
  37. CONFIG_STORAGE_KEY: 'masteringPhysicsExtractorConfig',
  38.  
  39. // UI identifiers
  40. PROGRESS_BAR_ID: 'mp-extractor-progress-bar',
  41. BUTTON_CONTAINER_ID: 'mp-extractor-button-container',
  42.  
  43. // Default settings (can be overridden by user)
  44. defaults: {
  45. debugMode: false, // Enable verbose logging
  46. navigationTimeout: 15000, // Max wait time for navigation elements (ms)
  47. extractionDelay: 500, // Delay between extraction steps (ms)
  48. buttonPosition: 'bottom-right', // Position of control buttons
  49. showProgressText: true, // Show detailed progress text
  50. downloadFormat: 'json', // Download format (only json for now)
  51. theme: { // UI theme colors
  52. extractButton: '#007bff',
  53. assignmentButton: '#28a745',
  54. cancelButton: '#dc3545',
  55. progressBar: '#4CAF50',
  56. progressBackground: '#555'
  57. }
  58. },
  59.  
  60. // Version information
  61. VERSION: '3.0.0',
  62. DATA_FORMAT_VERSION: '1.0'
  63. };
  64.  
  65. // --- Logger ---
  66. const Logger = {
  67. _enabled: true,
  68. _debugMode: CONFIG.defaults.debugMode,
  69.  
  70. /**
  71. * Initialize logger with settings
  72. * @param {boolean} enabled - Enable/disable logging
  73. * @param {boolean} debugMode - Enable verbose debug logs
  74. */
  75. init(enabled = true, debugMode = false) {
  76. this._enabled = enabled;
  77. this._debugMode = debugMode;
  78. this.log('Logger initialized', { enabled, debugMode });
  79. },
  80.  
  81. /**
  82. * Log a message to console
  83. * @param {string} message - Message to log
  84. * @param {*} [data] - Optional data to log
  85. */
  86. log(message, data) {
  87. if (!this._enabled) return;
  88. if (data !== undefined) {
  89. console.log(`MP Extractor: ${message}`, data);
  90. } else {
  91. console.log(`MP Extractor: ${message}`);
  92. }
  93. },
  94.  
  95. /**
  96. * Log a debug message (only shown in debug mode)
  97. * @param {string} message - Message to log
  98. * @param {*} [data] - Optional data to log
  99. */
  100. debug(message, data) {
  101. if (!this._enabled || !this._debugMode) return;
  102. if (data !== undefined) {
  103. console.debug(`MP Extractor [DEBUG]: ${message}`, data);
  104. } else {
  105. console.debug(`MP Extractor [DEBUG]: ${message}`);
  106. }
  107. },
  108.  
  109. /**
  110. * Log a warning message
  111. * @param {string} message - Message to log
  112. * @param {*} [data] - Optional data to log
  113. */
  114. warn(message, data) {
  115. if (!this._enabled) return;
  116. if (data !== undefined) {
  117. console.warn(`MP Extractor [WARNING]: ${message}`, data);
  118. } else {
  119. console.warn(`MP Extractor [WARNING]: ${message}`);
  120. }
  121. },
  122.  
  123. /**
  124. * Log an error message
  125. * @param {string} message - Message to log
  126. * @param {Error|*} [error] - Optional error to log
  127. */
  128. error(message, error) {
  129. if (!this._enabled) return;
  130. if (error !== undefined) {
  131. console.error(`MP Extractor [ERROR]: ${message}`, error);
  132. } else {
  133. console.error(`MP Extractor [ERROR]: ${message}`);
  134. }
  135. }
  136. };
  137.  
  138. // --- Storage Service ---
  139. const StorageService = {
  140. /**
  141. * Get an item from storage
  142. * @param {string} key - Storage key
  143. * @param {*} defaultValue - Default value if not found
  144. * @returns {Promise<*>} - Stored value or default
  145. */
  146. async getItem(key, defaultValue = null) {
  147. try {
  148. const value = await GM_getValue(key, defaultValue);
  149. Logger.debug(`Retrieved from storage: ${key}`, value);
  150. return value;
  151. } catch (error) {
  152. Logger.error(`Failed to get item from storage: ${key}`, error);
  153. return defaultValue;
  154. }
  155. },
  156.  
  157. /**
  158. * Save an item to storage
  159. * @param {string} key - Storage key
  160. * @param {*} value - Value to store
  161. * @returns {Promise<boolean>} - Success status
  162. */
  163. async setItem(key, value) {
  164. try {
  165. await GM_setValue(key, value);
  166. Logger.debug(`Saved to storage: ${key}`, value);
  167. return true;
  168. } catch (error) {
  169. Logger.error(`Failed to save item to storage: ${key}`, error);
  170. return false;
  171. }
  172. },
  173.  
  174. /**
  175. * Remove an item from storage
  176. * @param {string} key - Storage key
  177. * @returns {Promise<boolean>} - Success status
  178. */
  179. async removeItem(key) {
  180. try {
  181. await GM_deleteValue(key);
  182. Logger.debug(`Removed from storage: ${key}`);
  183. return true;
  184. } catch (error) {
  185. Logger.error(`Failed to remove item from storage: ${key}`, error);
  186. return false;
  187. }
  188. },
  189.  
  190. /**
  191. * Save JSON data to storage
  192. * @param {string} key - Storage key
  193. * @param {Object} data - Object to store as JSON
  194. * @returns {Promise<boolean>} - Success status
  195. */
  196. async setJSON(key, data) {
  197. try {
  198. const jsonString = JSON.stringify(data);
  199. return await this.setItem(key, jsonString);
  200. } catch (error) {
  201. Logger.error(`Failed to save JSON to storage: ${key}`, error);
  202. return false;
  203. }
  204. },
  205.  
  206. /**
  207. * Get JSON data from storage
  208. * @param {string} key - Storage key
  209. * @param {Object} defaultValue - Default value if not found or invalid
  210. * @returns {Promise<Object>} - Parsed object or default
  211. */
  212. async getJSON(key, defaultValue = {}) {
  213. try {
  214. const jsonString = await this.getItem(key);
  215. if (!jsonString) return defaultValue;
  216. return JSON.parse(jsonString);
  217. } catch (error) {
  218. Logger.error(`Failed to parse JSON from storage: ${key}`, error);
  219. return defaultValue;
  220. }
  221. }
  222. };
  223.  
  224. // --- DOM Utilities ---
  225. const DOMUtils = {
  226. /**
  227. * Wait for an element to be available and ready in the DOM
  228. * @param {string} selector - CSS selector for element
  229. * @param {number} timeout - Max wait time in ms
  230. * @returns {Promise<Element>} - Found element
  231. */
  232. async waitForElement(selector, timeout = CONFIG.defaults.navigationTimeout) {
  233. return new Promise((resolve, reject) => {
  234. const intervalTime = 100;
  235. let elapsedTime = 0;
  236.  
  237. // Check if element already exists
  238. const existingElement = document.querySelector(selector);
  239. if (existingElement && existingElement.offsetParent !== null &&
  240. !existingElement.disabled && !existingElement.classList.contains('disabled')) {
  241. return resolve(existingElement);
  242. }
  243.  
  244. const interval = setInterval(() => {
  245. const element = document.querySelector(selector);
  246. if (element && element.offsetParent !== null &&
  247. !element.disabled && !element.classList.contains('disabled')) {
  248. clearInterval(interval);
  249. resolve(element);
  250. } else {
  251. elapsedTime += intervalTime;
  252. if (elapsedTime >= timeout) {
  253. clearInterval(interval);
  254. reject(new Error(`Element "${selector}" not found/ready within ${timeout}ms`));
  255. }
  256. }
  257. }, intervalTime);
  258. });
  259. },
  260.  
  261. /**
  262. * Get the current navigation state of the app
  263. * @returns {Object} Navigation state object
  264. */
  265. getNavigationState() {
  266. const navPosElement = document.querySelector('#navigation .pos');
  267. const nextLink = document.querySelector('#next-item-link');
  268. const prevLinkDisabled = document.querySelector('#navigation .nav-circle.prev.disabled');
  269.  
  270. let current = 0, total = 0, isLast = true, isFirst = true, hasNext = false;
  271.  
  272. if (navPosElement) {
  273. const posText = navPosElement.textContent.trim();
  274. const match = posText.match(/(\d+)\s+of\s+(\d+)/);
  275.  
  276. if (match) {
  277. current = parseInt(match[1], 10);
  278. total = parseInt(match[2], 10);
  279. hasNext = !!nextLink && !nextLink.classList.contains('disabled');
  280. isLast = current === total || !hasNext;
  281. isFirst = current === 1 || !!prevLinkDisabled;
  282. } else {
  283. Logger.warn("Could not parse navigation position:", posText);
  284. current = 1;
  285. total = 1;
  286. }
  287. } else {
  288. Logger.warn("Navigation position element not found.");
  289. current = 1;
  290. total = 1;
  291. }
  292.  
  293. return { current, total, isLast, isFirst, hasNext };
  294. },
  295.  
  296. /**
  297. * Create an HTML element with properties
  298. * @param {string} tag - Element tag name
  299. * @param {Object} props - Element properties
  300. * @param {Object} styles - CSS styles to apply
  301. * @param {HTMLElement[]} children - Child elements to append
  302. * @returns {HTMLElement} Created element
  303. */
  304. createElement(tag, props = {}, styles = {}, children = []) {
  305. const element = document.createElement(tag);
  306.  
  307. // Apply properties
  308. Object.entries(props).forEach(([key, value]) => {
  309. if (key === 'textContent') {
  310. element.textContent = value;
  311. } else if (key === 'innerHTML') {
  312. element.innerHTML = value;
  313. } else if (key === 'className') {
  314. element.className = value;
  315. } else if (key === 'events') {
  316. Object.entries(value).forEach(([event, handler]) => {
  317. element.addEventListener(event, handler);
  318. });
  319. } else {
  320. element.setAttribute(key, value);
  321. }
  322. });
  323.  
  324. // Apply styles
  325. Object.assign(element.style, styles);
  326.  
  327. // Append children
  328. children.forEach(child => element.appendChild(child));
  329.  
  330. return element;
  331. }
  332. };
  333.  
  334. // --- Text Processing Utilities ---
  335. const TextUtils = {
  336. /**
  337. * Process scientific notation in an element
  338. * @param {HTMLElement} element - Element to process
  339. */
  340. processScientificNotation(element) {
  341. if (!element) return;
  342.  
  343. const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false);
  344. const nodesToProcess = [];
  345. let currentNode;
  346.  
  347. // Find text nodes with scientific notation
  348. while ((currentNode = walker.nextNode())) {
  349. if (currentNode.textContent.match(/\d+(\.\d+)?\s*[×xX]\s*10/)) {
  350. nodesToProcess.push(currentNode);
  351. }
  352. }
  353.  
  354. // Process each text node
  355. nodesToProcess.forEach(node => {
  356. let nextElement = node.nextSibling;
  357.  
  358. // Find the next element sibling
  359. while (nextElement && nextElement.nodeType !== Node.ELEMENT_NODE) {
  360. nextElement = nextElement.nextSibling;
  361. }
  362.  
  363. // Handle superscript exponent
  364. if (nextElement && nextElement.tagName === 'SUP') {
  365. const exponent = nextElement.textContent.trim();
  366. const text = node.textContent;
  367. const match = text.match(/(\d+(?:\.\d+)?)\s*[×xX]\s*10$/);
  368.  
  369. if (match) {
  370. const newText = text.replace(/(\d+(?:\.\d+)?)\s*[×xX]\s*10$/, `$1 × 10^{${exponent}}`);
  371. node.textContent = newText;
  372.  
  373. if (nextElement.parentNode) {
  374. try {
  375. nextElement.parentNode.removeChild(nextElement);
  376. } catch (e) {
  377. Logger.warn("Could not remove sup element:", e);
  378. }
  379. }
  380. }
  381. } else {
  382. // Normalize existing notation
  383. const text = node.textContent;
  384. const newText = text.replace(/(\d+(?:\.\d+)?)\s*[×xX]\s*10\^\{(-?\d+)\}/g, '$1 × 10^{$2}');
  385.  
  386. if (newText !== text) {
  387. node.textContent = newText;
  388. }
  389. }
  390. });
  391. },
  392.  
  393. /**
  394. * Process MathJax elements and replace with LaTeX
  395. * @param {HTMLElement} element - Element containing MathJax
  396. * @returns {string} - Processed text with LaTeX notations
  397. */
  398. processMathJax(element) {
  399. if (!element) return '';
  400.  
  401. const mathMap = new Map();
  402. const mathScripts = element.querySelectorAll('script[type="math/tex"]');
  403.  
  404. // Map placeholders to LaTeX content
  405. mathScripts.forEach((script, index) => {
  406. const placeholder = `__MATH_PLACEHOLDER_${index}__`;
  407. mathMap.set(placeholder, script.textContent.trim());
  408.  
  409. const span = document.createElement('span');
  410. span.textContent = placeholder;
  411.  
  412. try {
  413. if (script.parentNode) {
  414. script.parentNode.replaceChild(span, script);
  415. }
  416. } catch(e) {
  417. Logger.warn("Could not replace MathJax script:", e);
  418. }
  419. });
  420.  
  421. // Remove MathJax rendered elements
  422. const mathJaxElements = element.querySelectorAll('.MathJax_Preview, .MathJax');
  423. mathJaxElements.forEach(el => el.remove());
  424.  
  425. // Extract text content and replace placeholders with LaTeX
  426. let text = element.textContent || '';
  427.  
  428. mathMap.forEach((latex, placeholder) => {
  429. const regex = new RegExp(placeholder.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), 'g');
  430. text = text.replace(regex, `$${latex}$`);
  431. });
  432.  
  433. // Clean up text formatting
  434. text = text
  435. .replace(/(\d+(?:\.\d+)?)\s*[×xX]\s*10\s*\^\s*\{(-?\d+)\}/g, '$1 × 10^{$2}')
  436. .replace(/(\d+(?:\.\d+)?)\s*[×xX]\s*10\s*\^\s*(-?\d+)/g, '$1 × 10^{$2}')
  437. .replace(/\s{2,}/g, ' ')
  438. .trim();
  439.  
  440. return text;
  441. },
  442.  
  443. /**
  444. * Extract text with proper MathJax/LaTeX handling
  445. * @param {HTMLElement} element - Element to extract text from
  446. * @returns {string} - Extracted text with LaTeX
  447. */
  448. extractTextWithMathJax(element) {
  449. if (!element) return '';
  450.  
  451. const clone = element.cloneNode(true);
  452. this.processScientificNotation(clone);
  453. return this.processMathJax(clone);
  454. }
  455. };
  456.  
  457. // --- Content Extractors ---
  458. const ContentExtractors = {
  459. /**
  460. * Extract the introduction text
  461. * @returns {string} - Extracted introduction
  462. */
  463. extractIntroduction() {
  464. const introElement = document.querySelector('.introduction.edible');
  465. return introElement ? TextUtils.extractTextWithMathJax(introElement) : "";
  466. },
  467.  
  468. /**
  469. * Extract figures from the problem
  470. * @returns {Array<Object>} - Array of figure objects
  471. */
  472. extractFigures() {
  473. const figures = [];
  474. const flipperElement = document.querySelector('.flipper');
  475.  
  476. if (!flipperElement) return figures;
  477.  
  478. const figureCountText = flipperElement.querySelector('#itemcount')?.textContent;
  479. const figureCount = figureCountText ? parseInt(figureCountText.trim(), 10) : 0;
  480. const mediaElements = flipperElement.querySelectorAll('.media');
  481.  
  482. mediaElements.forEach((media, index) => {
  483. const imageElement = media.querySelector('img');
  484.  
  485. if (imageElement) {
  486. figures.push({
  487. index: index + 1,
  488. totalFigures: figureCount,
  489. src: imageElement.getAttribute('src') || '',
  490. alt: imageElement.getAttribute('alt') || '',
  491. title: imageElement.getAttribute('title') || ''
  492. });
  493. }
  494. });
  495.  
  496. return figures;
  497. },
  498.  
  499. /**
  500. * Extract ranking items from a problem
  501. * @param {HTMLElement} problemElement - Problem container element
  502. * @returns {Object|null} - Ranking items data or null if not present
  503. */
  504. extractRankingItems(problemElement) {
  505. const rankingElement = problemElement.querySelector('.solutionAppletRanking');
  506.  
  507. if (!rankingElement) return null;
  508.  
  509. const items = [];
  510. const rankItems = problemElement.querySelectorAll('.rank-item');
  511.  
  512. rankItems.forEach(item => {
  513. const mathScript = item.querySelector('script[type="math/tex"]');
  514.  
  515. if (mathScript && mathScript.textContent) {
  516. items.push({ text: `$${mathScript.textContent.trim()}$` });
  517. } else {
  518. items.push({ text: TextUtils.extractTextWithMathJax(item).trim() });
  519. }
  520. });
  521.  
  522. let largestText = 'Largest', smallestText = 'Smallest';
  523. const preText = problemElement.querySelector('.rank-pre-text');
  524. const postText = problemElement.querySelector('.rank-post-text');
  525.  
  526. if (preText) largestText = TextUtils.extractTextWithMathJax(preText).trim();
  527. if (postText) smallestText = TextUtils.extractTextWithMathJax(postText).trim();
  528.  
  529. return { items, directions: { largest: largestText, smallest: smallestText } };
  530. },
  531.  
  532. /**
  533. * Extract multiple choice options from a problem
  534. * @param {HTMLElement} problemElement - Problem container element
  535. * @returns {Array<Object>|null} - Multiple choice options or null if not present
  536. */
  537. extractMultipleChoice(problemElement) {
  538. const multipleChoiceElement = problemElement.querySelector('.solutionMultipleChoiceRadio');
  539.  
  540. if (!multipleChoiceElement) return null;
  541.  
  542. const choices = [];
  543. const optionContainers = multipleChoiceElement.querySelectorAll('table.tidy-options > tbody > tr.grouper');
  544.  
  545. if (optionContainers.length === 0) {
  546. // Fallback for simpler layouts
  547. const simpleOptions = multipleChoiceElement.querySelectorAll('.option-label');
  548.  
  549. if (simpleOptions.length > 0) {
  550. Logger.debug("Using fallback MC extractor for simple options.");
  551.  
  552. simpleOptions.forEach((option, index) => {
  553. choices.push({
  554. index: index + 1,
  555. text: TextUtils.extractTextWithMathJax(option).trim()
  556. });
  557. });
  558.  
  559. return choices.length > 0 ? choices : null;
  560. } else {
  561. return null;
  562. }
  563. }
  564.  
  565. // Process table rows for complex layouts
  566. optionContainers.forEach((container, index) => {
  567. const choiceData = { index: index + 1 };
  568. const label = container.querySelector('label.option-label');
  569.  
  570. if (!label) {
  571. Logger.warn("MC option container missing label:", container);
  572. return;
  573. }
  574.  
  575. // Extract figure if present
  576. const imageElement = label.querySelector('img');
  577. if (imageElement) {
  578. choiceData.figure = {
  579. src: imageElement.getAttribute('src') || '',
  580. alt: imageElement.getAttribute('alt') || '',
  581. title: imageElement.getAttribute('title') || ''
  582. };
  583. }
  584.  
  585. // Extract text (excluding figure)
  586. const labelClone = label.cloneNode(true);
  587. const imageInClone = labelClone.querySelector('img');
  588.  
  589. if (imageInClone) {
  590. imageInClone.remove();
  591. }
  592.  
  593. const textContent = TextUtils.extractTextWithMathJax(labelClone).trim();
  594.  
  595. if (textContent) {
  596. choiceData.text = textContent;
  597. }
  598.  
  599. // Add choice if it has either text or figure
  600. if (choiceData.figure || choiceData.text) {
  601. choices.push(choiceData);
  602. } else {
  603. Logger.warn("MC option label yielded no text or figure:", label);
  604. }
  605. });
  606.  
  607. return choices.length > 0 ? choices : null;
  608. },
  609.  
  610. /**
  611. * Extract units from a problem
  612. * @param {HTMLElement} problemElement - Problem container element
  613. * @returns {string} - Unit text
  614. */
  615. extractUnit(problemElement) {
  616. const postTextDiv = problemElement.querySelector('.postTextDiv');
  617. return postTextDiv ? TextUtils.extractTextWithMathJax(postTextDiv).trim() : '';
  618. },
  619.  
  620. /**
  621. * Extract data for a single problem part
  622. * @param {HTMLElement} problemElement - Problem part container
  623. * @returns {Object} - Problem part data
  624. */
  625. extractProblemPart(problemElement) {
  626. const partLabel = problemElement.querySelector('.autolabel')?.textContent.trim() || 'Unknown Part';
  627. const questionElement = problemElement.querySelector('.text.edible');
  628. const questionText = questionElement
  629. ? TextUtils.extractTextWithMathJax(questionElement)
  630. : 'No question text found';
  631.  
  632. const instructionsElement = problemElement.querySelector('.instructions.edible');
  633. const instructions = instructionsElement
  634. ? TextUtils.extractTextWithMathJax(instructionsElement)
  635. : '';
  636.  
  637. const equationLabel = problemElement.querySelector('.preTextDiv');
  638. const equationText = equationLabel
  639. ? TextUtils.extractTextWithMathJax(equationLabel)
  640. : '';
  641.  
  642. const unit = this.extractUnit(problemElement);
  643. const multipleChoiceOptions = this.extractMultipleChoice(problemElement);
  644. const rankingItems = this.extractRankingItems(problemElement);
  645.  
  646. const partData = { part: partLabel, question: questionText };
  647.  
  648. if (instructions) partData.instructions = instructions;
  649. if (equationText) partData.equation = equationText;
  650. if (unit) partData.unit = unit;
  651. if (multipleChoiceOptions) partData.multipleChoiceOptions = multipleChoiceOptions;
  652. if (rankingItems) partData.rankingItems = rankingItems;
  653.  
  654. return partData;
  655. },
  656.  
  657. /**
  658. * Extract all problem parts from the current page
  659. * @returns {Array<Object>} - Array of problem part data
  660. */
  661. extractAllProblemParts() {
  662. const partSections = document.querySelectorAll('.section.part');
  663.  
  664. if (partSections.length === 0) {
  665. const mainProblemArea = document.querySelector('.problem-view');
  666.  
  667. if (mainProblemArea) {
  668. const singlePart = this.extractProblemPart(mainProblemArea);
  669.  
  670. if (singlePart.question && singlePart.question !== 'No question text found') {
  671. if (singlePart.part === 'Unknown Part') singlePart.part = "Part A";
  672. return [singlePart];
  673. }
  674. }
  675.  
  676. return [];
  677. }
  678.  
  679. return Array.from(partSections).map(section => this.extractProblemPart(section));
  680. },
  681.  
  682. /**
  683. * Extract all data from the current page
  684. * @returns {Object} - Complete page data
  685. */
  686. extractCurrentPageData() {
  687. const url = window.location.href;
  688. const timestamp = new Date().toISOString();
  689. const navState = DOMUtils.getNavigationState();
  690. const currentPosition = navState.current > 0 ? navState.current : 'N/A';
  691. const totalItems = navState.total > 0 ? navState.total : 'N/A';
  692.  
  693. return {
  694. pageUrl: url,
  695. extractedAt: timestamp,
  696. position: currentPosition,
  697. totalItemsInAssignment: totalItems,
  698. introduction: this.extractIntroduction(),
  699. figures: this.extractFigures(),
  700. problemParts: this.extractAllProblemParts()
  701. };
  702. }
  703. };
  704.  
  705. // --- Data Export Service ---
  706. const DataExporter = {
  707. /**
  708. * Download data as JSON file
  709. * @param {Object} data - Data to download
  710. * @param {string} filename - Filename for the download
  711. * @returns {Promise<boolean>} - Success status
  712. */
  713. async downloadAsJSON(data, filename) {
  714. try {
  715. // Add extractor version to metadata
  716. if (data && typeof data === 'object') {
  717. if (!data.metadata) data.metadata = {};
  718. data.metadata.extractorVersion = CONFIG.VERSION;
  719. data.metadata.dataFormatVersion = CONFIG.DATA_FORMAT_VERSION;
  720. }
  721.  
  722. const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json;charset=utf-8' });
  723. const url = URL.createObjectURL(blob);
  724.  
  725. const a = document.createElement('a');
  726. a.href = url;
  727. a.download = filename;
  728. a.style.display = 'none';
  729.  
  730. document.body.appendChild(a);
  731. a.click();
  732.  
  733. Logger.log(`Downloading data as ${filename}`);
  734.  
  735. // Clean up
  736. return new Promise(resolve => {
  737. setTimeout(() => {
  738. try {
  739. if (a.parentNode) document.body.removeChild(a);
  740. URL.revokeObjectURL(url);
  741. Logger.debug("Download link revoked.");
  742. resolve(true);
  743. } catch(cleanupError) {
  744. Logger.warn("Error during download link cleanup:", cleanupError);
  745. resolve(false);
  746. }
  747. }, 200);
  748. });
  749. } catch (error) {
  750. Logger.error("Error during JSON download:", error);
  751. alert("Error creating download file.");
  752. return false;
  753. }
  754. }
  755. };
  756.  
  757. // --- UI Components ---
  758. const UI = {
  759. /**
  760. * Progress bar component
  761. */
  762. ProgressBar: {
  763. element: null,
  764. textElement: null,
  765. barElement: null,
  766. cancelButton: null,
  767. controller: null,
  768.  
  769. /**
  770. * Initialize progress bar
  771. * @param {Object} controller - App controller reference
  772. * @param {Object} theme - Theme colors
  773. */
  774. init(controller, theme) {
  775. this.controller = controller;
  776. this.theme = theme;
  777. },
  778.  
  779. /**
  780. * Show the progress bar
  781. * @param {number} current - Current progress
  782. * @param {number} total - Total items
  783. */
  784. show(current = 0, total = 1) {
  785. if (!this.element) this.create();
  786. this.update(current, total);
  787. this.element.style.display = 'flex';
  788. this.controller.setButtonsDisabled(true);
  789. },
  790.  
  791. /**
  792. * Create the progress bar DOM elements
  793. */
  794. create() {
  795. this.element = DOMUtils.createElement('div',
  796. { id: CONFIG.PROGRESS_BAR_ID },
  797. {
  798. position: 'fixed',
  799. top: '10px',
  800. left: '50%',
  801. transform: 'translateX(-50%)',
  802. width: '80%',
  803. maxWidth: '600px',
  804. backgroundColor: 'rgba(0, 0, 0, 0.8)',
  805. color: 'white',
  806. padding: '10px 15px',
  807. borderRadius: '8px',
  808. zIndex: '10001',
  809. display: 'flex',
  810. alignItems: 'center',
  811. justifyContent: 'space-between',
  812. boxShadow: '0 2px 10px rgba(0,0,0,0.5)',
  813. fontSize: '14px'
  814. }
  815. );
  816.  
  817. this.textElement = DOMUtils.createElement('span',
  818. {},
  819. { flexGrow: '1', marginRight: '15px' }
  820. );
  821.  
  822. const barContainer = DOMUtils.createElement('div',
  823. {},
  824. {
  825. width: '100px',
  826. height: '10px',
  827. backgroundColor: this.theme.progressBackground || '#555',
  828. borderRadius: '5px',
  829. overflow: 'hidden',
  830. marginRight: '15px'
  831. }
  832. );
  833.  
  834. this.barElement = DOMUtils.createElement('div',
  835. {},
  836. {
  837. width: '0%',
  838. height: '100%',
  839. backgroundColor: this.theme.progressBar || '#4CAF50',
  840. transition: 'width 0.3s ease-in-out'
  841. }
  842. );
  843.  
  844. this.cancelButton = DOMUtils.createElement('button',
  845. {
  846. textContent: 'Cancel',
  847. events: { click: () => this.controller.cancelAssignmentExtraction() }
  848. },
  849. {
  850. padding: '5px 10px',
  851. backgroundColor: this.theme.cancelButton || '#dc3545',
  852. color: 'white',
  853. border: 'none',
  854. borderRadius: '5px',
  855. cursor: 'pointer',
  856. fontWeight: 'bold',
  857. fontSize: '12px'
  858. }
  859. );
  860.  
  861. barContainer.appendChild(this.barElement);
  862. this.element.appendChild(this.textElement);
  863. this.element.appendChild(barContainer);
  864. this.element.appendChild(this.cancelButton);
  865.  
  866. document.body.appendChild(this.element);
  867. },
  868.  
  869. /**
  870. * Update progress bar status
  871. * @param {number} current - Current progress
  872. * @param {number} total - Total items
  873. */
  874. update(current, total) {
  875. if (!this.element || !this.textElement || !this.barElement) return;
  876.  
  877. const percentage = total > 0 ? Math.min(100, Math.round((current / total) * 100)) : 0;
  878. this.textElement.textContent = `Extracting Item ${current} of ${total}... (${percentage}%)`;
  879. this.barElement.style.width = `${percentage}%`;
  880. },
  881.  
  882. /**
  883. * Hide the progress bar
  884. */
  885. hide() {
  886. if (this.element) this.element.style.display = 'none';
  887. this.controller.setButtonsDisabled(false);
  888. },
  889.  
  890. /**
  891. * Remove the progress bar from DOM
  892. */
  893. remove() {
  894. if (this.element && this.element.parentNode) {
  895. this.element.parentNode.removeChild(this.element);
  896. this.element = null;
  897. this.textElement = null;
  898. this.barElement = null;
  899. this.cancelButton = null;
  900. }
  901.  
  902. this.controller.setButtonsDisabled(false);
  903. }
  904. },
  905.  
  906. /**
  907. * Settings dialog component
  908. */
  909. SettingsDialog: {
  910. element: null,
  911. controller: null,
  912.  
  913. /**
  914. * Initialize settings dialog
  915. * @param {Object} controller - App controller reference
  916. */
  917. init(controller) {
  918. this.controller = controller;
  919. },
  920.  
  921. /**
  922. * Show the settings dialog
  923. */
  924. show() {
  925. if (!this.element) this.create();
  926. this.element.style.display = 'block';
  927. },
  928.  
  929. /**
  930. * Create the settings dialog DOM elements
  931. */
  932. create() {
  933. // Implementation would go here
  934. // Creates a dialog with settings options from CONFIG.defaults
  935. // For brevity, not fully implemented in this version
  936. alert("Settings functionality will be added in a future version");
  937. },
  938.  
  939. /**
  940. * Hide the settings dialog
  941. */
  942. hide() {
  943. if (this.element) this.element.style.display = 'none';
  944. }
  945. }
  946. };
  947.  
  948. // --- App Controller ---
  949. class AppController {
  950. constructor() {
  951. this.config = { ...CONFIG.defaults };
  952. this.initialButtons = {};
  953. this.init();
  954. }
  955.  
  956. /**
  957. * Initialize the app controller
  958. */
  959. async init() {
  960. Logger.log("Initializing...");
  961.  
  962. try {
  963. // Load user configuration
  964. const storedConfig = await StorageService.getJSON(CONFIG.CONFIG_STORAGE_KEY);
  965. if (storedConfig && Object.keys(storedConfig).length > 0) {
  966. this.config = { ...this.config, ...storedConfig };
  967. Logger.debug("Loaded stored configuration", this.config);
  968. }
  969.  
  970. // Initialize logger with config settings
  971. Logger.init(true, this.config.debugMode);
  972.  
  973. // Initialize UI components
  974. UI.ProgressBar.init(this, this.config.theme);
  975.  
  976. // Register menu commands
  977. this.registerMenuCommands();
  978.  
  979. // Add control buttons
  980. this.addInitialButtons();
  981.  
  982. // Check for ongoing extraction
  983. setTimeout(async () => {
  984. try {
  985. const isInProgress = await StorageService.getItem(CONFIG.EXTRACTION_IN_PROGRESS_KEY) === 'true';
  986.  
  987. if (isInProgress) {
  988. Logger.log("Continuing assignment extraction...");
  989. await this.handleExtractionStep();
  990. } else {
  991. Logger.log("Ready.");
  992. UI.ProgressBar.remove();
  993. this.setButtonsDisabled(false);
  994. }
  995. } catch (error) {
  996. Logger.error("Error during init check:", error);
  997. alert("Error during script initialization.");
  998. await StorageService.removeItem(CONFIG.EXTRACTION_IN_PROGRESS_KEY);
  999. UI.ProgressBar.remove();
  1000. this.setButtonsDisabled(false);
  1001. }
  1002. }, this.config.extractionDelay);
  1003.  
  1004. } catch (error) {
  1005. Logger.error("Initialization error:", error);
  1006. alert("Failed to initialize MasteringPhysics Extractor.");
  1007. }
  1008. }
  1009.  
  1010. /**
  1011. * Register Tampermonkey menu commands
  1012. */
  1013. registerMenuCommands() {
  1014. if (typeof GM_registerMenuCommand !== 'undefined') {
  1015. GM_registerMenuCommand('Extract Current Problem', () => this.extractSingleProblem());
  1016. GM_registerMenuCommand('Extract Full Assignment', () => this.startAssignmentExtraction());
  1017. GM_registerMenuCommand('About MasteringPhysics Extractor', () => {
  1018. alert(
  1019. `MasteringPhysics Extractor v${CONFIG.VERSION}\n\n` +
  1020. `Extract problems from MasteringPhysics to JSON format.\n\n` +
  1021. `Use the buttons in the bottom-right corner to extract the current problem or the entire assignment.`
  1022. );
  1023. });
  1024. }
  1025. }
  1026.  
  1027. /**
  1028. * Add control buttons to the page
  1029. */
  1030. addInitialButtons() {
  1031. if (document.getElementById(CONFIG.BUTTON_CONTAINER_ID)) {
  1032. Logger.log("Buttons already added.");
  1033. return;
  1034. }
  1035.  
  1036. // Container for buttons
  1037. const buttonContainer = DOMUtils.createElement('div',
  1038. { id: CONFIG.BUTTON_CONTAINER_ID },
  1039. {
  1040. position: 'fixed',
  1041. bottom: '20px',
  1042. right: '20px',
  1043. zIndex: '10000',
  1044. display: 'flex',
  1045. flexDirection: 'column',
  1046. gap: '10px'
  1047. }
  1048. );
  1049.  
  1050. // Helper to create a button
  1051. const createButton = (text, color, onClick) => {
  1052. return DOMUtils.createElement('button',
  1053. {
  1054. textContent: text,
  1055. events: { click: onClick }
  1056. },
  1057. {
  1058. padding: '8px 12px',
  1059. backgroundColor: color,
  1060. color: 'white',
  1061. border: 'none',
  1062. borderRadius: '5px',
  1063. cursor: 'pointer',
  1064. fontWeight: 'bold',
  1065. fontSize: '13px'
  1066. }
  1067. );
  1068. };
  1069.  
  1070. // Create and add buttons
  1071. this.initialButtons.single = createButton(
  1072. 'Extract This Item',
  1073. this.config.theme.extractButton,
  1074. () => this.extractSingleProblem()
  1075. );
  1076.  
  1077. this.initialButtons.assignment = createButton(
  1078. 'Extract Full Assignment',
  1079. this.config.theme.assignmentButton,
  1080. () => this.startAssignmentExtraction()
  1081. );
  1082.  
  1083. buttonContainer.appendChild(this.initialButtons.single);
  1084. buttonContainer.appendChild(this.initialButtons.assignment);
  1085.  
  1086. document.body.appendChild(buttonContainer);
  1087. Logger.log("Extractor buttons added to page.");
  1088. }
  1089.  
  1090. /**
  1091. * Enable/disable control buttons
  1092. * @param {boolean} disabled - Whether buttons should be disabled
  1093. */
  1094. setButtonsDisabled(disabled) {
  1095. Object.values(this.initialButtons).forEach(button => {
  1096. if (button) button.disabled = disabled;
  1097. });
  1098. }
  1099.  
  1100. /**
  1101. * Extract the current problem
  1102. */
  1103. async extractSingleProblem() {
  1104. Logger.log("Extracting single item...");
  1105.  
  1106. try {
  1107. const problemData = ContentExtractors.extractCurrentPageData();
  1108.  
  1109. // Construct metadata for single item download
  1110. const singleItemOutput = {
  1111. metadata: {
  1112. url: problemData.pageUrl,
  1113. extractedAt: problemData.extractedAt,
  1114. type: 'MasteringPhysics Single Item',
  1115. position: problemData.position,
  1116. totalItemsInAssignment: problemData.totalItemsInAssignment,
  1117. extractorVersion: CONFIG.VERSION,
  1118. dataFormatVersion: CONFIG.DATA_FORMAT_VERSION
  1119. },
  1120. item: problemData
  1121. };
  1122.  
  1123. const filename = `mastering_item_${problemData.position || Date.now()}.json`;
  1124. await DataExporter.downloadAsJSON(singleItemOutput, filename);
  1125.  
  1126. Logger.log("Single item extracted.");
  1127. } catch (error) {
  1128. Logger.error("Error extracting single problem:", error);
  1129. alert("Failed to extract the current problem.");
  1130. }
  1131. }
  1132.  
  1133. /**
  1134. * Start extracting the full assignment
  1135. */
  1136. async startAssignmentExtraction() {
  1137. Logger.log("Starting assignment extraction...");
  1138.  
  1139. try {
  1140. // Check if extraction already in progress
  1141. if (await StorageService.getItem(CONFIG.EXTRACTION_IN_PROGRESS_KEY) === 'true') {
  1142. alert("An extraction is already in progress.");
  1143. return;
  1144. }
  1145.  
  1146. // Confirm with user
  1147. if (!confirm("This will navigate through all problems in the assignment. Continue?")) {
  1148. Logger.log("Assignment extraction cancelled by user.");
  1149. return;
  1150. }
  1151.  
  1152. // Show progress
  1153. const navState = DOMUtils.getNavigationState();
  1154. UI.ProgressBar.show(navState.current, navState.total);
  1155.  
  1156. // Initialize extraction state
  1157. const initialState = {
  1158. metadata: {
  1159. startUrl: window.location.href,
  1160. extractedAt: new Date().toISOString(),
  1161. type: 'MasteringPhysics Assignment',
  1162. extractorVersion: CONFIG.VERSION,
  1163. dataFormatVersion: CONFIG.DATA_FORMAT_VERSION
  1164. },
  1165. items: []
  1166. };
  1167.  
  1168. // Save initial state
  1169. await StorageService.setJSON(CONFIG.STORAGE_KEY, initialState);
  1170. await StorageService.setItem(CONFIG.EXTRACTION_IN_PROGRESS_KEY, 'true');
  1171.  
  1172. Logger.log("Extraction state initialized.");
  1173.  
  1174. // Start extraction
  1175. await this.handleExtractionStep();
  1176. } catch (error) {
  1177. Logger.error("Error starting assignment extraction:", error);
  1178. alert("Failed to initialize extraction. Aborting.");
  1179. UI.ProgressBar.remove();
  1180. }
  1181. }
  1182.  
  1183. /**
  1184. * Handle a single step in the extraction process
  1185. */
  1186. async handleExtractionStep() {
  1187. try {
  1188. // Check if extraction still in progress
  1189. const extractionState = await StorageService.getItem(CONFIG.EXTRACTION_IN_PROGRESS_KEY);
  1190.  
  1191. if (extractionState !== 'true') {
  1192. Logger.log("Extraction not in progress flag found. Stopping.");
  1193. UI.ProgressBar.remove();
  1194. return;
  1195. }
  1196.  
  1197. // Get current navigation state
  1198. const navState = DOMUtils.getNavigationState();
  1199. UI.ProgressBar.show(navState.current, navState.total);
  1200.  
  1201. Logger.log(`Handling extraction step for item: ${navState.current}/${navState.total}`);
  1202.  
  1203. // Extract data from current page
  1204. const currentPageData = ContentExtractors.extractCurrentPageData();
  1205. Logger.log(`Extracted data for item ${currentPageData.position}`);
  1206.  
  1207. // Get and update stored data
  1208. let assignmentData = await StorageService.getJSON(CONFIG.STORAGE_KEY, { metadata: {}, items: [] });
  1209.  
  1210. // Check if this item is already stored
  1211. const exists = assignmentData.items.some(item =>
  1212. item.position === currentPageData.position &&
  1213. item.pageUrl === currentPageData.pageUrl
  1214. );
  1215.  
  1216. if (!exists) {
  1217. assignmentData.items.push(currentPageData);
  1218. await StorageService.setJSON(CONFIG.STORAGE_KEY, assignmentData);
  1219. Logger.log(`Added item ${currentPageData.position}. Total stored: ${assignmentData.items.length}`);
  1220. } else {
  1221. Logger.log(`Item ${currentPageData.position} already stored. Skipping add.`);
  1222. }
  1223.  
  1224. // Re-check navigation state after processing
  1225. const currentNavState = DOMUtils.getNavigationState();
  1226. UI.ProgressBar.update(currentNavState.current, currentNavState.total);
  1227.  
  1228. // Decide next action
  1229. if (!currentNavState.isLast && currentNavState.hasNext) {
  1230. Logger.log("Attempting navigation...");
  1231.  
  1232. try {
  1233. const nextButton = await DOMUtils.waitForElement('#next-item-link', this.config.navigationTimeout);
  1234. Logger.log("Next button ready. Clicking.");
  1235. nextButton.click();
  1236. } catch (navError) {
  1237. Logger.error("Failed to find/click next button:", navError);
  1238. alert("Error navigating. Extraction stopped.");
  1239. await this.finalizeExtraction();
  1240. }
  1241. } else {
  1242. Logger.log("Last item reached or cannot navigate.");
  1243. await this.finalizeExtraction();
  1244. }
  1245. } catch (error) {
  1246. Logger.error("Error in extraction step:", error);
  1247. alert("Error during extraction process. Stopping.");
  1248. await this.finalizeExtraction();
  1249. }
  1250. }
  1251.  
  1252. /**
  1253. * Finalize the extraction process
  1254. */
  1255. async finalizeExtraction() {
  1256. Logger.log("Finalizing extraction...");
  1257.  
  1258. let finalData = null;
  1259. let initialMetadata = {};
  1260.  
  1261. try {
  1262. // Get stored data
  1263. const storedDataString = await StorageService.getItem(CONFIG.STORAGE_KEY);
  1264.  
  1265. if (storedDataString) {
  1266. finalData = JSON.parse(storedDataString);
  1267.  
  1268. // Preserve initial metadata
  1269. if (finalData && finalData.metadata) {
  1270. initialMetadata = {
  1271. startUrl: finalData.metadata.startUrl,
  1272. extractedAt: finalData.metadata.extractedAt,
  1273. type: finalData.metadata.type,
  1274. extractorVersion: finalData.metadata.extractorVersion,
  1275. dataFormatVersion: finalData.metadata.dataFormatVersion
  1276. };
  1277. } else {
  1278. Logger.warn("Metadata missing from stored data during finalization.");
  1279.  
  1280. if (!finalData) finalData = { items: [] };
  1281. finalData.metadata = {};
  1282. }
  1283. }
  1284. } catch (parseError) {
  1285. Logger.error("Failed to parse final stored data. Cannot generate final file.", parseError);
  1286. alert("Error reading final data. Extraction cannot be saved.");
  1287.  
  1288. // Cleanup even on error
  1289. await StorageService.removeItem(CONFIG.STORAGE_KEY);
  1290. await StorageService.removeItem(CONFIG.EXTRACTION_IN_PROGRESS_KEY);
  1291. UI.ProgressBar.remove();
  1292. return;
  1293. }
  1294.  
  1295. // Check if we have items to save
  1296. if (!finalData || !finalData.items || finalData.items.length === 0) {
  1297. Logger.error("Finalizing, but no items found in data!");
  1298. alert("Extraction finished, but no items were collected.");
  1299. } else {
  1300. // Update metadata with final information
  1301. const finalMetadata = {
  1302. ...initialMetadata,
  1303. totalItemsExtracted: finalData.items.length,
  1304. extractionFinishedAt: new Date().toISOString()
  1305. };
  1306.  
  1307. finalData.metadata = finalMetadata;
  1308.  
  1309. // Download the file
  1310. const filename = `mastering_assignment_${Date.now()}.json`;
  1311. await DataExporter.downloadAsJSON(finalData, filename);
  1312.  
  1313. Logger.log(`Download initiated for ${finalData.items.length} items.`);
  1314. }
  1315.  
  1316. // Clean up
  1317. try {
  1318. await StorageService.removeItem(CONFIG.STORAGE_KEY);
  1319. await StorageService.removeItem(CONFIG.EXTRACTION_IN_PROGRESS_KEY);
  1320. Logger.log("Storage cleaned up.");
  1321. } catch (cleanupError) {
  1322. Logger.error("Error cleaning storage after finalization:", cleanupError);
  1323. } finally {
  1324. UI.ProgressBar.remove();
  1325.  
  1326. if (finalData && finalData.items && finalData.items.length > 0) {
  1327. alert(`Assignment extraction complete! ${finalData.items.length} items processed.`);
  1328. }
  1329. }
  1330. }
  1331.  
  1332. /**
  1333. * Cancel the ongoing assignment extraction
  1334. * @param {boolean} confirmFirst - Whether to ask for confirmation
  1335. */
  1336. async cancelAssignmentExtraction(confirmFirst = true) {
  1337. Logger.log("Attempting to cancel extraction...");
  1338.  
  1339. if (confirmFirst && !confirm("Cancel the ongoing assignment extraction?")) {
  1340. Logger.log("Cancellation aborted by user.");
  1341. return;
  1342. }
  1343.  
  1344. try {
  1345. await StorageService.removeItem(CONFIG.STORAGE_KEY);
  1346. await StorageService.removeItem(CONFIG.EXTRACTION_IN_PROGRESS_KEY);
  1347.  
  1348. Logger.log("Assignment extraction cancelled and storage cleaned.");
  1349.  
  1350. if (confirmFirst) {
  1351. alert("Assignment extraction cancelled.");
  1352. }
  1353. } catch (error) {
  1354. Logger.error("Error cleaning up storage during cancellation:", error);
  1355. alert("Error during cancellation cleanup.");
  1356. } finally {
  1357. UI.ProgressBar.remove();
  1358. }
  1359. }
  1360. }
  1361.  
  1362. // --- Script Initialization ---
  1363. window.addEventListener('load', () => {
  1364. // Check if required Tampermonkey functions are available
  1365. if (typeof GM_setValue === 'undefined' ||
  1366. typeof GM_getValue === 'undefined' ||
  1367. typeof GM_deleteValue === 'undefined') {
  1368.  
  1369. console.error("MP Extractor: GM_* functions not available. Check @grant in userscript header.");
  1370. alert("MP Extractor Error: Tampermonkey API functions not found. Make sure you've installed this script correctly.");
  1371. return;
  1372. }
  1373.  
  1374. // Initialize the controller
  1375. new AppController();
  1376. });
  1377. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址