Greasy Fork

来自缓存

Greasy Fork is available in English.

Udemy - Improved Course Library

Adds current ratings, and other detailed data to all courses in your Udemy library

当前为 2022-04-22 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Udemy - Improved Course Library
// @namespace    https://github.com/tadwohlrapp
// @description  Adds current ratings, and other detailed data to all courses in your Udemy library
// @icon         https://raw.githubusercontent.com/tadwohlrapp/udemy-improved-course-library/main/src/icon48.png
// @icon64       https://raw.githubusercontent.com/tadwohlrapp/udemy-improved-course-library/main/src/icon64.png
// @author       Tad Wohlrapp (https://github.com/tadwohlrapp)
// @homepageURL  https://github.com/tadwohlrapp/udemy-improved-course-library
// @version      1.0.4
// @supportURL   https://github.com/tadwohlrapp/udemy-improved-course-library/issues
// @match        https://www.udemy.com/home/my-courses/*
// @compatible   chrome Tested with Tampermonkey v4.13 and Violentmonkey v2.13.0
// @compatible   firefox Tested with Greasemonkey v4.11
// @license      MIT
// @run-at       document-end
// ==/UserScript==


fetchCourses();

const mutationObserver = new MutationObserver(fetchCourses);
const observerConfig = {
  childList: true,
  subtree: true
};
mutationObserver.observe(document, observerConfig);

const i18n = loadTranslations();
const lang = getLang(document.documentElement.lang);

function fetchCourses() {
  listenForArchiveToggle();
  const courseContainers = document.querySelectorAll('[data-purpose="enrolled-course-card"]:not(.details-done)');
  if (courseContainers.length == 0) { return; }
  [...courseContainers].forEach((courseContainer) => {

    const isPartialRefresh = courseContainer.classList.contains('partial-refresh');

    const courseId = courseContainer.querySelector('.card--learning__image').href.replace('https://www.udemy.com/course-dashboard-redirect/?course_id=', '');

    const courseCustomDiv = document.createElement('div');
    courseCustomDiv.classList.add('card__custom', 'js-removepartial');

    courseContainer.appendChild(courseCustomDiv);
    courseContainer.classList.add('details-done');
    courseContainer.classList.remove('partial-refresh');

    // Add Link to course overview to options dropdown
    const courseLinkLi = document.createElement('li');
    courseLinkLi.innerHTML = `
      <a class="udlite-btn udlite-btn-large udlite-btn-ghost udlite-text-sm udlite-block-list-item udlite-block-list-item-small udlite-block-list-item-neutral" role="menuitem" tabindex="-1" href="https://www.udemy.com/course/${courseId}/" target="_blank" rel="noopener">
        <span class="udi-small udi udi-explore udlite-block-list-item-icon"></span>
        <div class="udlite-block-list-item-content card__course-link">${i18n[lang].overview}
          <svg fill="#686f7a" width="12" height="16" viewBox="0 0 24 24" style="vertical-align: bottom; margin-left: 5px;" xmlns="http://www.w3.org/2000/svg">
            <path d="M19 19H5V5h7V3H5a2 2 0 00-2 2v14c0 1.1.9 2 2 2h14a2 2 0 002-2v-7h-2v7zM14 3v2h3.6l-9.8 9.8 1.4 1.4L19 6.4V10h2V3h-7z"></path>
          </svg>
        </div>
      </a>
    `;
    courseLinkLi.classList.add('js-removepartial');

    const allDropdowns = courseContainer.querySelectorAll('.udlite-block-list');
    if (allDropdowns[1]) {
      allDropdowns[1].appendChild(courseLinkLi);;
    }

    // Find existing elements in DOM
    const thumbnailDiv = courseContainer.querySelector('.card__image');
    const detailsName = courseContainer.querySelector('.details__name');
    const detailsInstructor = courseContainer.querySelector('.details__instructor');
    const progressText = courseContainer.querySelector('.progress__text');
    const progressBar = courseContainer.querySelector('.details__progress');
    const startCourseText = courseContainer.querySelector('.details__start-course');
    const detailsBottom = courseContainer.querySelector('.details__bottom');

    // If progress made
    if (progressText != null) {
      // Add progress bar below thumbnail
      const progressBarSpan = document.createElement('span');
      progressBarSpan.classList.add('impr__progress-bar', 'js-removepartial');
      progressBarSpan.innerHTML = progressBar.innerHTML;
      thumbnailDiv.appendChild(progressBarSpan);
      // Add progress percentage to thumbnail bottom right
      const progressTextSpan = document.createElement('span');
      progressTextSpan.classList.add('card__thumb-overlay', 'card__course-runtime', 'hover-show', 'js-removepartial');
      progressTextSpan.innerHTML = progressText.innerHTML;
      thumbnailDiv.appendChild(progressTextSpan);
      // Remove existing progress percentage
      progressText.parentNode.removeChild(progressText);
    }

    // Remove existing progress bar
    if (!isPartialRefresh) {
      progressBar.parentNode.removeChild(progressBar);
    }

    // If "START COURSE" exists, remove it. It's clutter
    if (startCourseText != null) {
      startCourseText.parentNode.removeChild(startCourseText);
    }

    if (!isPartialRefresh) {
      // If instructor title exists, remove it as well
      const instructorTitle = detailsInstructor.querySelector('span');
      if (instructorTitle != null) {
        instructorTitle.parentNode.removeChild(instructorTitle);
      }

      // Switch classes on course name and instructor
      detailsName.classList.add('impr__name');
      detailsName.classList.remove('details__name');
      detailsInstructor.classList.add('impr__instructor');
      detailsInstructor.classList.remove('details__instructor');
    }

    // If course page has draft status, do not even to fetch its data via API
    if (courseContainer.querySelector('.card--learning__details .card__details a').href.includes('/draft/')) {
      if (!isPartialRefresh) {
        detailsBottom.parentNode.removeChild(detailsBottom);
      }
      courseContainer.querySelector('.card__course-link').style.textDecoration = "line-through";
      courseCustomDiv.classList.add('card__nodata');
      courseCustomDiv.innerHTML += i18n[lang].notavailable;
      // We're done with this course
      return;
    }

    const fetchUrl = 'https://www.udemy.com/api-2.0/courses/' + courseId + '?fields[course]=rating,num_reviews,num_subscribers,content_length_video,last_update_date,locale,has_closed_caption,caption_languages,num_published_lectures';
    fetch(fetchUrl)
      .then(response => {
        if (response.ok) {
          return response.json();
        } else {
          throw new Error(response.status);
        }
      })
      .then(json => {
        if (typeof json === 'undefined') { return; }

        // Get everything from JSON and put it in variables
        const rating = json.rating.toFixed(1);
        const reviews = json.num_reviews;
        const enrolled = json.num_subscribers;
        const runtime = json.content_length_video;
        const updateDate = json.last_update_date;
        const locale = json.locale.title;
        const localeCode = json.locale.locale;
        const hasCaptions = json.has_closed_caption;
        const captionsLangs = json.caption_languages;

        // Format "Last updated" Date
        let updateDateShort = '';
        let updateDateLong = '';
        if (updateDate) {
          updateDateShort = updateDate.replace(/(\d{4})-(\d{2})-(\d{2})/, '$2\/$1');
          updateDateLong = new Date(updateDate).toLocaleDateString(lang, { year: 'numeric', month: 'long', day: 'numeric' });
        }

        // Small helper for rating strip color
        const getColor = v => `hsl(${(Math.round((1 - v) * 120))},100%,45%)`;
        const colorValue = r => Math.min(Math.max((5 - r) / 2, 0), 1);

        // If captions are available, create the tag for it. We'll add it in template string later
        let captionsTag = '';
        if (hasCaptions) {
          const captionsString = captionsLangs.join('&#013;&#010;');
          captionsTag = `
            <div class="impr__tooltip" data-tooltip="${captionsString}">
              <i class="udi udi-closed-caption"></i>
            </div>
          `;
        }

        // Returns true or false depending if stars are visible
        const isShowingStars = courseContainer.querySelector('.details__bottom--review');

        // Now let's handle own ratings

        // Set up empty html
        let myRatingHtml = '';
        let ratingButton;
        let ratingOwn = 0;

        // If ratings stars ARE visible, proceed to build own rating stars
        if (isShowingStars != null) {

          // Find the rating-button, and remove its css class
          ratingButton = isShowingStars.querySelector('button');

          // If I have voted, count the stars and tell me how I voted
          ratingOwn = getRatingFromSvg(ratingButton.querySelector('svg')); // between 0 and 5

          // Remove the old stars from ratingButton
          ratingButton.removeChild(ratingButton.querySelector('span'));

          // Build the html
          myRatingHtml = `
            <span class="impr__stars-ct">
              <span class="impr__stars">
                ${buildStars(ratingOwn)}
              </span>
              <span class="impr__review">
                <span class="impr__review-stat">${setDecimal(ratingOwn, lang)}</span>
                <span class="impr__review-count">(<span class="review-button"></span>)</span>
              </span>
            </span>
          `;
        }

        const ratingStripColor = ratingOwn > 0 ? ratingOwn : rating;

        let updateDateInfo = '';
        if (updateDateShort !== '' && updateDateLong !== '') {
          updateDateInfo = `
            <div class="impr__tooltip" data-tooltip="${i18n[lang].updated}${updateDateLong}">
              <i class="udi udi-resend"></i><span>${updateDateShort}</span>
            </div>
          `;
        }

        courseCustomDiv.innerHTML = `
          <div class="impr__rating">
            <span class="impr__stars-ct">
              <span class="impr__stars">
                ${buildStars(rating)}
              </span>
              <span class="impr__review">
                <span class="impr__review-stat">${setDecimal(rating, lang)}</span>
                <span class="impr__review-count">(${setSeparator(reviews, lang)})</span>
              </span>
            </span>
            ${myRatingHtml}
          </div>
          <div class="impr__rating-strip" style="background-color:${getColor(colorValue(ratingStripColor))}"></div>
          <div class="impr__stats">
            <div class="impr__tooltip" data-tooltip="${setSeparator(enrolled, lang)} ${i18n[lang].enrolled}">
              <i class="udi udi-users"></i><span>${setSeparator(enrolled, lang)}</span>
            </div>
            ${updateDateInfo}
            ${captionsTag}
          </div>
        `;

        if (isShowingStars != null) {
          const reviewButtonContainer = courseCustomDiv.querySelector('.review-button');
          ratingButton.style.display = 'inline';
          reviewButtonContainer.appendChild(ratingButton);
        }

        if (!isPartialRefresh) {
          detailsBottom.parentNode.removeChild(detailsBottom);
        }

        // Hide language badge if language is English
        if (localeCode.slice(0, 2) !== 'en') {
          const localeSpan = document.createElement('span');
          localeSpan.classList.add('card__thumb-overlay', 'card__course-locale', 'hover-hide', 'js-removepartial');
          localeSpan.innerHTML = `<span style="margin-right: 3px;vertical-align: bottom;font-size: 14px;line-height: 13px;">${getFlagEmoji(localeCode.slice(-2))}</span>${locale}`;
          thumbnailDiv.appendChild(localeSpan);
        }

        // Add course runtime from API to thumbnail bottom right
        const runtimeSpan = document.createElement('span');
        runtimeSpan.classList.add('card__thumb-overlay', 'card__course-runtime', 'hover-hide', 'js-removepartial');
        runtimeSpan.innerHTML = parseRuntime(runtime, lang);
        thumbnailDiv.appendChild(runtimeSpan);
      })
      .catch(error => {
        courseCustomDiv.classList.add('card__nodata');
        courseCustomDiv.innerHTML += `<div><b>${error}</b><br>${i18n[lang].notavailable}</div>`;
        if (detailsBottom != null) {
          detailsBottom.parentNode.removeChild(detailsBottom);
        }
      });
  });
}

function listenForArchiveToggle() {
  document.querySelectorAll('[data-purpose="toggle-archived"]').forEach(item => {
    item.addEventListener('click', event => {
      mutationObserver.disconnect();
      let thisCourse = item.closest('.details-done');
      if (thisCourse != null) {
        thisCourse.classList.add('partial-refresh');

        while (thisCourse.nextElementSibling != null) {
          thisCourse.nextElementSibling.classList.add('partial-refresh');
          thisCourse = thisCourse.nextElementSibling;
        }
      }

      const brokenContainers = document.querySelectorAll('.partial-refresh');
      [...brokenContainers].forEach((brokenContainer) => {
        brokenContainer.classList.remove('details-done');
        let removeElements = brokenContainer.getElementsByClassName('js-removepartial');
        while (removeElements[0]) {
          removeElements[0].parentNode.removeChild(removeElements[0]);
        }
      });

      mutationObserver.observe(document, observerConfig);
    });
  });
}

function setSeparator(int, lang) {
  return int.toString().replace(/\B(?=(\d{3})+(?!\d))/g, i18n[lang].separator);
}

function setDecimal(rating, lang) {
  return rating.toString().replace('.', i18n[lang].decimal);
}

function getLang(lang) {
  return i18n.hasOwnProperty(lang) ? lang : 'en-us';
}

function buildStars(rating) {
  let starTemplate = '';
  let remainder = 0;
  for (let i = 0; i < 5; i++) {
    let percent = 0;
    if (Math.floor(rating) > i) {
      percent = 100;
    } else if (remainder == 0) {
      remainder = rating % 1;
      percent = Math.round(remainder * 10) * 10
    }
    starTemplate += `
      <div>
        <span class="impr__star impr__star--unfilled"></span>
        <span class="impr__star impr__star--filled" style="width: ${percent.toString()}%;"></span>
      </div>
    `;
  }
  return starTemplate;
}

function parseRuntime(seconds, lang) {
  if (seconds % 60 > 29) { seconds += 30; }
  let hours = Math.floor(seconds / 60 / 60);
  let minutes = Math.floor(seconds / 60) - (hours * 60);
  let hoursFormatted = hours > 0 ? hours.toString() + i18n[lang].hours : '';
  let minutesFormatted = minutes > 0 ? ' ' + minutes.toString() + i18n[lang].mins : '';
  return hoursFormatted + minutesFormatted;
}

function getRatingFromSvg(svgElement) {
  let percentage = svgElement.querySelector('mask rect').getAttribute('width');
  let rating = parseFloat(percentage) / 100 * 5;
  return rating;
}

function loadTranslations() {
  return {
    'en-us': {
      'overview': 'Course overview',
      'enrolled': 'students',
      'updated': 'Last updated ',
      'notavailable': 'Course info not available',
      'separator': ',',
      'decimal': '.',
      'hours': 'h',
      'mins': 'm'
    },
    'de-de': {
      'overview': 'Kursübersicht',
      'enrolled': 'Teilnehmer',
      'updated': 'Zuletzt aktualisiert ',
      'notavailable': 'Kursinfo nicht verfügbar',
      'separator': '.',
      'decimal': ',',
      'hours': ' Std',
      'mins': ' Min'
    },
    'es-es': {
      'overview': 'Descripción del curso',
      'enrolled': 'estudiantes',
      'updated': 'Última actualización ',
      'notavailable': 'La información del curso no está disponible',
      'separator': '.',
      'decimal': ',',
      'hours': ' h',
      'mins': ' m'
    },
    'fr-fr': {
      'overview': 'Aperçu du cours',
      'enrolled': 'participants',
      'updated': 'Dernière mise à jour : ',
      'notavailable': 'Informations sur les cours non disponibles',
      'separator': ' ',
      'decimal': ',',
      'hours': ' h',
      'mins': ' min'
    },
    'it-it': {
      'overview': 'Panoramica del corso',
      'enrolled': 'studenti',
      'updated': 'Ultimo aggiornamento ',
      'notavailable': 'Informazioni sul corso non disponibili',
      'separator': '.',
      'decimal': ',',
      'hours': ' h',
      'mins': ' min'
    },
    'ja-jp': {
      'overview': 'コースの概要',
      'enrolled': '受講生',
      'updated': '最終更新日 ',
      'notavailable': 'コースの情報はありません。',
      'separator': ',',
      'decimal': '.',
      'hours': '時間',
      'mins': '分'
    }
  };
}

function getFlagEmoji(countryCode) {
  const codePoints = countryCode
    .split('')
    .map(char => 127397 + char.charCodeAt());
  return String.fromCodePoint(...codePoints);
}

const style = document.createElement('style');
style.textContent = `
.card--learning {
  box-shadow: 0 0 1px 1px rgba(20, 23, 28, 0.1),
    0 3px 1px 0 rgba(20, 23, 28, 0.1);
  transition: all 100ms linear;
}

.card--learning:hover {
  box-shadow: 0 2px 8px 2px rgba(20, 23, 28, 0.15);
}

.card--learning:before {
  content: none;
}

.card--learning:after {
  content: none;
}

.card__image {
  overflow: inherit;
}

.card__image .course-image {
  transition: opacity linear 100ms;
  box-shadow: 0 1px 0 0 rgba(232, 233, 235, 0.5);
  -webkit-filter: sepia(0.1) grayscale(0.1) saturate(0.8);
  filter: sepia(0.1) grayscale(0.1) saturate(0.8);
}

a:hover .card__image .course-image {
  opacity: 0.8;
}

.card--learning__details {
  border-top: 1px solid #e8e9eb;
}

.card__details {
  padding: 12px;
  height: 66px;
  white-space: initial;
}

.impr__name {
  font-weight: 700;
  line-height: 1.2;
  letter-spacing: -0.2px;
  font-size: 14px;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.impr__instructor {
  color: #73726c;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  margin-top: 4px;
  font-size: 12px;
}

span[class^='leave-rating--helper-text'] {
  font-size: 10px;
  white-space: nowrap;
}

.card__thumb-overlay {
  position: absolute;
  display: inline-block;
  font-size: 10px;
  font-weight: 700;
  margin: 4px;
  padding: 2px 4px;
  border-radius: 2px;
  transition: opacity linear 100ms;
}

.card__course-link {
  font-size: 1.4rem;
}

.card__course-runtime {
  bottom: 0;
  right: 0;
  background-color: rgba(20, 30, 46, 0.75);
  color: #ffffff;
}

.impr__progress-bar ~ .card__course-runtime {
  bottom: 4px;
}

.card__course-locale {
  top: 0;
  left: 0;
  background-color: rgba(255, 255, 255, 0.9);
  box-shadow: 0 0 1px 1px rgba(20, 23, 28, 0.1);
  color: #29303b;
  font-weight: 600;
}

.play-button-trigger .hover-hide {
  opacity: 1;
}

.play-button-trigger .hover-show {
  opacity: 0;
}

.play-button-trigger:hover .hover-hide {
  opacity: 0;
}

.play-button-trigger:hover .hover-show {
  opacity: 1;
}

.impr__progress-bar {
  display: block;
  position: absolute;
  bottom: 0;
  right: 0;
  left: 0;
  height: 5px;
  background: rgba(20, 30, 46, 0.75);
}

.impr__progress-bar .progress__bar {
  background: #a435ef !important;
}

.card__custom {
  font-size: 12px;
  color: #464b53;
  height: 85px;
}

.impr__rating {
  padding: 0 12px;
  height: 48px;
}

.impr__rating-strip {
  height: 5px;
}

.impr__stats {
  font-weight: 500;
  padding: 5px 12px;
  line-height: 1.7;
  display: flex;
}

.impr__stats > div {
  display: inline-block;
  background: #f7f8fa;
  padding: 0 5px;
  margin-right: 5px;
  border-radius: 2px;
  border: 1px solid #e7e7e8;
  cursor: default;
}

.impr__stats .udi {
  opacity: 0.75;
  vertical-align: middle;
}

.impr__stats .udi:not(:last-child) {
  margin-right: 4px;
}

.impr__star-own {
  font-size: 16px;
  margin-right: 2px;
}

.card__stars {
  display: inline-block;
  width: 7rem;
  height: 1.6rem;
  vertical-align: text-bottom;
}

.card__star--bordered {
  stroke: #eb8a2f;
}

.card__star--filled {
  fill: #eb8a2f;
}

.card__rating-text {
  font-weight: 700;
  color: #505763;
  margin-left: 2px;
  margin-right: 6px;
  font-size: 14px;
}

.impr__icon {
  width: 12px;
  height: 15px;
  fill: currentColor;
  vertical-align: text-top;
  margin-right: 4px;
  opacity: 0.75;
}

.card__nodata {
  font-size: 13px;
  display: flex;
  justify-content: center;
  align-items: center;
  text-align: center;
  height: 75px;
  margin-top: 10px;
  padding: 12px;
  background: #fbf4f4;
  color: #521822;
}

.impr__rating-all {
  height: 20px;
}

.impr__rating-btn {
  position: relative;
  height: 20px;
  flex-direction: row !important;
  align-items: center !important;
  justify-content: flex-end;
}

.impr__rating-btn > .impr__rating-own {
  position: absolute;
}

.impr__rating-btn.is-rated > span {
  opacity: 0;
}

.impr__rating-btn.is-rated:hover > span {
  opacity: 1;
}

.impr__rating-btn.is-rated:hover > .impr__rating-own {
  opacity: 0;
}

.impr__tooltip {
  display: inline;
  position: relative;
}

.impr__tooltip:hover:after {
  display: flex;
  justify-content: center;
  background: #4f5662;
  border-radius: 3px;
  color: #fff;
  content: attr(data-tooltip);
  margin: 4px 0 0 -50%;
  font-size: 11px;
  padding: 2px 6px;
  position: absolute;
  z-index: 10;
  white-space: pre;
}

.impr__tooltip:hover:before {
  border: solid;
  border-color: #4f5662 transparent;
  border-width: 0px 4px 6px 4px;
  content: '';
  left: 50%;
  margin-left: -4px;
  bottom: -4px;
  position: absolute;
}

.impr__stars-ct {
  margin: 0;
  padding: 4px 0 0;
  display: flex;
}

.impr__stars {
  font-size: 13px;
  display: inline-block;
  white-space: nowrap;
}

.impr__stars div {
  display: inline-block;
  position: relative;
}

.impr__star {
  top: 0;
  left: 0;
}

.impr__star:before {
  font-family: udemyicons;
  display: inline-block;
  position: relative;
  line-height: 1;
}

.impr__star--unfilled {
  position: relative;
}

.impr__star--unfilled:before {
  z-index: 0;
  content: '\\F005';
  color: #dedfe0;
}

.impr__star--filled {
  position: absolute;
  overflow: hidden;
}

.impr__star--filled:before {
  z-index: 1;
  content: '\\F005';
  color: #f4c150;
}

.impr__review {
  margin-left: 5px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.impr__review-stat {
  font-weight: 700;
  font-size: 13px;
  color: #505763;
}

.impr__review-count {
  font-weight: 400;
  color: #686f7a;
  margin-left: 2px;
}`;
document.documentElement.appendChild(style);