1.购买服务器阿里云:服务器购买地址https://t.aliyun.com/U/Bg6shY若失效,可用地址
阿里云:
服务器购买地址
https://t.aliyun.com/U/Bg6shY若失效,可用地址
https://www.aliyun.com/daily-act/ecs/activity_selection?source=5176.29345612&userCode=49hts92d腾讯云:
https://curl.qcloud.com/wJpWmSfU若失效,可用地址
https://cloud.tencent.com/act/cps/redirect?redirect=2446&cps_key=ad201ee2ef3b771157f72ee5464b1fea&from=console华为云
https://activity.huaweicloud.com/cps.html?fromacct=64b5cf7cc11b4840bb4ed2ea0b2f4468&utm_source=V1g3MDY4NTY=&utm_medium=cps&utm_campaign=2019052.部署教程
3.代码如下
(function () {'use strict';const SELECTORS = {player: {container: "#players",coverPlayBtn: ".pv-cover .pv-icon-btn-play",playPauseBtn: ".pv-playpause",timeCurrent: ".pv-time-current",timeDuration: ".pv-time-duration",playBtnClass: "pv-icon-btn-play",pauseBtnClass: "pv-icon-btn-pause"},course: {menu: ".cg-player-menu",chapterSection: ".cg-play-chapter-section",chapterIndex: ".cg-play-chapter-index",chapterTitle: ".cg-play-chapter-title .cg-player-text2",chapterDuration: ".cg-play-chapter-title .cg-player-text1",lessonNode: ".cg-play-node",lessonName: ".cg-player-text2",lessonDuration: ".cg-f-fe-c span",lessonClickable: ".cg-text-row1",playingIndicator: ".cg-textp"}};const CONFIG = {timeout: {element: 15e3,playButton: 1e4},stability: {checks: 3},autoPlay: {afterEnd: 3e3,afterClick: 500,lessonChangeDelay: 2e3},playEnd: {tolerance: 1,resetThreshold: 5},monitor: {rebindInterval: 3e3}};class DOMUtils {static async waitForElement(selector, timeout = CONFIG.timeout.element) {return new Promise((resolve) => {let foundElement = null;let stableCount = 0;const checkElement = () => {const element = document.querySelector(selector);if (element) {if (foundElement === element) {stableCount++;if (stableCount >= CONFIG.stability.checks) {observer.disconnect();clearTimeout(timeoutId);resolve(element);}} else {foundElement = element;stableCount = 1;}} else {foundElement = null;stableCount = 0;}};const observer = new MutationObserver(checkElement);observer.observe(document.body, { childList: true, subtree: true });const timeoutId = setTimeout(() => {observer.disconnect();resolve(foundElement);}, timeout);checkElement();});}static isElementVisible(element) {return !!element && document.body.contains(element) && element.style.display !== "none";}static safeClick(element) {if (!element || !this.isElementVisible(element)) return false;element.click();return true;}static getTextContent(element) {return element?.textContent?.trim() || "";}static queryText(parent, selector) {return this.getTextContent(parent.querySelector(selector));}}const PREFIX = "[AboutCG]";class Logger {static info(message, ...args) {console.log(`${PREFIX} ${message}`, ...args);}static success(message, ...args) {console.log(`${PREFIX} ✅ ${message}`, ...args);}static error(message, ...args) {console.error(`${PREFIX} ❌ ${message}`, ...args);}static warn(message, ...args) {console.warn(`${PREFIX} ⚠️ ${message}`, ...args);}static debug(message, ...args) {console.debug(`${PREFIX} 🔍 ${message}`, ...args);}static special(emoji, message, ...args) {console.log(`${PREFIX} ${emoji} ${message}`, ...args);}}class CourseDataProvider {cachedCourseData = null;parse(forceRefresh = false) {if (this.cachedCourseData && !forceRefresh) {return this.cachedCourseData;}const chapters = [];let currentLesson;const chapterElements = document.querySelectorAll(SELECTORS.course.chapterSection);chapterElements.forEach((chapterEl) => {const chapterIndex = parseInt(DOMUtils.queryText(chapterEl, SELECTORS.course.chapterIndex) || "0");const chapterName = DOMUtils.queryText(chapterEl, SELECTORS.course.chapterTitle);const chapterDuration = DOMUtils.queryText(chapterEl, SELECTORS.course.chapterDuration);if (!chapterName) return;const lessons = [];const lessonElements = chapterEl.querySelectorAll(SELECTORS.course.lessonNode);lessonElements.forEach((lessonEl, lessonIdx) => {const lessonName = DOMUtils.queryText(lessonEl, SELECTORS.course.lessonName);const lessonDuration = DOMUtils.queryText(lessonEl, SELECTORS.course.lessonDuration);const playingEl = lessonEl.querySelector(SELECTORS.course.playingIndicator);const isPlaying = DOMUtils.getTextContent(playingEl).includes("正在播放");if (!lessonName) return;if (isPlaying) {currentLesson = {chapterIndex,lessonIndex: lessonIdx,lessonName};}lessons.push({name: lessonName,duration: lessonDuration,isPlaying,index: lessonIdx,element: lessonEl});});chapters.push({index: chapterIndex,name: chapterName,duration: chapterDuration,lessons});});this.cachedCourseData = { chapters, currentLesson };return this.cachedCourseData;}clearCache() {this.cachedCourseData = null;}getCurrentPosition(courseData) {if (!courseData.currentLesson) return null;const { chapterIndex, lessonIndex } = courseData.currentLesson;const currentChapterIdx = courseData.chapters.findIndex((ch) => ch.index === chapterIndex);if (currentChapterIdx === -1) return null;const currentChapter = courseData.chapters[currentChapterIdx];const currentLesson = currentChapter.lessons[lessonIndex];if (!currentLesson) return null;const result = {current: {chapter: currentChapter,lesson: currentLesson,chapterIndex: currentChapterIdx,lessonIndex}};if (lessonIndex > 0) {result.previous = {chapter: currentChapter,lesson: currentChapter.lessons[lessonIndex - 1],chapterIndex: currentChapterIdx,lessonIndex: lessonIndex - 1};} else if (currentChapterIdx > 0) {const prevChapter = courseData.chapters[currentChapterIdx - 1];const prevLessonIdx = prevChapter.lessons.length - 1;if (prevLessonIdx >= 0) {result.previous = {chapter: prevChapter,lesson: prevChapter.lessons[prevLessonIdx],chapterIndex: currentChapterIdx - 1,lessonIndex: prevLessonIdx};}}if (lessonIndex < currentChapter.lessons.length - 1) {result.next = {chapter: currentChapter,lesson: currentChapter.lessons[lessonIndex + 1],chapterIndex: currentChapterIdx,lessonIndex: lessonIndex + 1};} else if (currentChapterIdx < courseData.chapters.length - 1) {const nextChapter = courseData.chapters[currentChapterIdx + 1];if (nextChapter.lessons.length > 0) {result.next = {chapter: nextChapter,lesson: nextChapter.lessons[0],chapterIndex: currentChapterIdx + 1,lessonIndex: 0};}}return result;}getNextLesson(courseData) {const position = this.getCurrentPosition(courseData);return position?.next?.lesson || null;}getPreviousLesson(courseData) {const position = this.getCurrentPosition(courseData);return position?.previous?.lesson || null;}logCourseData(courseData) {Logger.info("课程数据解析完成");Logger.info(`章节总数: ${courseData.chapters.length}`);courseData.chapters.forEach((chapter) => {Logger.info(`第${chapter.index}章: ${chapter.name} (${chapter.duration})`);Logger.info(` - 小节数: ${chapter.lessons.length}`);chapter.lessons.forEach((lesson, idx) => {const playingMark = lesson.isPlaying ? "▶️ " : "";Logger.info(` ${idx + 1}. ${playingMark}${lesson.name} (${lesson.duration})`);});});if (courseData.currentLesson) {Logger.info(`当前播放: 第${courseData.currentLesson.chapterIndex}章 - ${courseData.currentLesson.lessonName}`);const position = this.getCurrentPosition(courseData);if (position) {Logger.info("小节定位信息:");Logger.info(` 当前: 第${position.current.chapter.index}章 第${position.current.lessonIndex + 1}节 - ${position.current.lesson.name}`);if (position.previous) {Logger.info(` 上一节: 第${position.previous.chapter.index}章 第${position.previous.lessonIndex + 1}节 - ${position.previous.lesson.name}`);} else {Logger.info(" 上一节: 无(已是第一节)");}if (position.next) {Logger.info(` 下一节: 第${position.next.chapter.index}章 第${position.next.lessonIndex + 1}节 - ${position.next.lesson.name}`);} else {Logger.info(" 下一节: 无(已是最后一节)");}}}}}class CourseObserverService {observer = null;callbacks = {};courseDataProvider;currentPlayingLesson = null;constructor(courseDataProvider) {this.courseDataProvider = courseDataProvider;}setCallbacks(callbacks) {this.callbacks = { ...this.callbacks, ...callbacks };}start() {const courseMenu = document.querySelector(SELECTORS.course.menu);if (!courseMenu) return;this.currentPlayingLesson = this.findCurrentPlayingLesson(this.courseDataProvider.parse());this.observer = new MutationObserver(() => this.handleCourseMenuChange());this.observer.observe(courseMenu, {childList: true,subtree: true,attributes: true,attributeFilter: ["class"]});}stop() {this.observer?.disconnect();this.observer = null;}handleCourseMenuChange() {const newPlayingLesson = this.findCurrentPlayingLesson(this.courseDataProvider.parse(true));if (newPlayingLesson && newPlayingLesson.element !== this.currentPlayingLesson?.element) {const oldLesson = this.currentPlayingLesson;this.currentPlayingLesson = newPlayingLesson;this.callbacks.onLessonChange?.(newPlayingLesson, oldLesson || void 0);}}findCurrentPlayingLesson(courseData) {for (const chapter of courseData.chapters) {const playing = chapter.lessons.find((lesson) => lesson.isPlaying);if (playing) return playing;}return null;}getCurrentLesson() {return this.currentPlayingLesson;}}class PlayButtonService {async smartClick() {const coverBtn = await DOMUtils.waitForElement(SELECTORS.player.coverPlayBtn, CONFIG.timeout.element);if (coverBtn && DOMUtils.isElementVisible(coverBtn)) {const freshBtn = document.querySelector(SELECTORS.player.coverPlayBtn);if (freshBtn && DOMUtils.safeClick(freshBtn)) {Logger.success("已点击封面播放按钮");return true;}}await new Promise((resolve) => setTimeout(resolve, 1e3));const playBtn = document.querySelector(SELECTORS.player.playPauseBtn);if (playBtn?.classList.contains(SELECTORS.player.playBtnClass) && DOMUtils.safeClick(playBtn)) {Logger.success("已点击控制栏播放按钮");return true;}Logger.error("自动播放失败");return false;}isPlaying() {const playPauseBtn = document.querySelector(SELECTORS.player.playPauseBtn);return playPauseBtn?.classList.contains(SELECTORS.player.pauseBtnClass) || false;}}class TimeUtils {static parseTimeToSeconds(timeStr) {const parts = timeStr.split(":").map(Number);if (parts.length === 3) {return parts[0] * 3600 + parts[1] * 60 + parts[2];} else if (parts.length === 2) {return parts[0] * 60 + parts[1];}return 0;}static formatSeconds(seconds) {const hours = Math.floor(seconds / 3600);const minutes = Math.floor(seconds % 3600 / 60);const secs = Math.floor(seconds % 60);const pad = (num) => num.toString().padStart(2, "0");if (hours > 0) {return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`;}return `${pad(minutes)}:${pad(secs)}`;}static calculatePercentage(current, total) {if (total <= 0) return "0.00";return (current / total * 100).toFixed(2);}static isTimeClose(time1, time2, tolerance) {return Math.abs(time1 - time2) <= tolerance;}}class PlayerMonitorService {observers = [];callbacks = {};lastTime = "";hasEnded = false;boundTimeElement = null;checkInterval = null;setCallbacks(callbacks) {this.callbacks = { ...this.callbacks, ...callbacks };}start() {this.bindObservers();this.startContainerObserver();this.startPeriodicCheck();}stop() {this.cleanup();if (this.checkInterval) {clearInterval(this.checkInterval);this.checkInterval = null;}}rebind() {this.boundTimeElement = null;this.bindObservers();}bindObservers() {const currentTimeEl = document.querySelector(SELECTORS.player.timeCurrent);const durationEl = document.querySelector(SELECTORS.player.timeDuration);const playPauseBtn = document.querySelector(SELECTORS.player.playPauseBtn);if (!currentTimeEl || !durationEl || this.boundTimeElement === currentTimeEl) {return;}this.cleanup();this.boundTimeElement = currentTimeEl;this.hasEnded = false;this.lastTime = "";const timeObserver = new MutationObserver(() => this.handleTimeUpdate(currentTimeEl, durationEl));timeObserver.observe(currentTimeEl, { childList: true, characterData: true, subtree: true });this.observers.push(timeObserver);if (playPauseBtn) {const btnObserver = new MutationObserver(() => this.handleStateChange(playPauseBtn));btnObserver.observe(playPauseBtn, { attributes: true, attributeFilter: ["class"] });this.observers.push(btnObserver);}}handleTimeUpdate(currentTimeEl, durationEl) {const currentTime = currentTimeEl.textContent?.trim() || "00:00";const duration = durationEl.textContent?.trim() || "00:00";if (currentTime === this.lastTime) return;this.lastTime = currentTime;const currentSeconds = TimeUtils.parseTimeToSeconds(currentTime);const durationSeconds = TimeUtils.parseTimeToSeconds(duration);const percentage = parseFloat(TimeUtils.calculatePercentage(currentSeconds, durationSeconds));Logger.info(`播放时间: ${currentTime} / ${duration} (${percentage.toFixed(2)}%)`);this.callbacks.onTimeUpdate?.(currentTime, duration, percentage);if (!this.hasEnded && durationSeconds > 0 && TimeUtils.isTimeClose(currentSeconds, durationSeconds, CONFIG.playEnd.tolerance)) {this.hasEnded = true;Logger.special("🎉", "视频播放完成!");this.callbacks.onPlayEnd?.();}if (this.hasEnded && currentSeconds < durationSeconds - CONFIG.playEnd.resetThreshold) {this.hasEnded = false;}}handleStateChange(playPauseBtn) {if (playPauseBtn.classList.contains(SELECTORS.player.playBtnClass)) {Logger.info("视频已暂停");this.callbacks.onStateChange?.("paused");} else if (playPauseBtn.classList.contains(SELECTORS.player.pauseBtnClass)) {this.callbacks.onStateChange?.("playing");}}startContainerObserver() {const playerContainer = document.querySelector(SELECTORS.player.container);if (!playerContainer) return;const containerObserver = new MutationObserver(() => {const currentTimeEl = document.querySelector(SELECTORS.player.timeCurrent);if (currentTimeEl && this.boundTimeElement !== currentTimeEl) {this.bindObservers();}});containerObserver.observe(playerContainer, { childList: true, subtree: true });this.observers.push(containerObserver);}startPeriodicCheck() {this.checkInterval = setInterval(() => {const currentTimeEl = document.querySelector(SELECTORS.player.timeCurrent);if (currentTimeEl && !this.boundTimeElement) {this.bindObservers();}}, CONFIG.monitor.rebindInterval);}cleanup() {this.observers.forEach((observer) => observer.disconnect());this.observers = [];this.boundTimeElement = null;}}class AutoPlayController {courseDataProvider;playerMonitor;courseObserver;playButtonService;constructor() {this.courseDataProvider = new CourseDataProvider();this.playerMonitor = new PlayerMonitorService();this.courseObserver = new CourseObserverService(this.courseDataProvider);this.playButtonService = new PlayButtonService();}async initialize() {Logger.info("AboutCG Auto Video 已加载");if (await DOMUtils.waitForElement(SELECTORS.course.menu)) {const courseData = this.courseDataProvider.parse();this.courseDataProvider.logCourseData(courseData);}if (!await DOMUtils.waitForElement(SELECTORS.player.timeCurrent)) {Logger.error("播放器控件加载超时");return;}this.playerMonitor.setCallbacks({ onPlayEnd: () => this.handlePlayEnd() });this.courseObserver.setCallbacks({ onLessonChange: (newLesson, oldLesson) => this.handleLessonChange(newLesson, oldLesson) });this.courseObserver.start();if (!await DOMUtils.waitForElement(`${SELECTORS.player.coverPlayBtn}, ${SELECTORS.player.playPauseBtn}`, CONFIG.timeout.playButton)) {Logger.error("播放按钮加载超时");return;}setTimeout(() => this.startAutoPlay(), CONFIG.autoPlay.afterClick);}async startAutoPlay() {this.playerMonitor.start();if (!this.playButtonService.isPlaying()) {await this.playButtonService.smartClick();}}handlePlayEnd() {setTimeout(() => this.playNextLesson(), CONFIG.autoPlay.afterEnd);}async handleLessonChange(newLesson, oldLesson) {Logger.info(`小节切换: ${oldLesson?.name || "无"} -> ${newLesson.name}`);await new Promise((resolve) => setTimeout(resolve, CONFIG.autoPlay.lessonChangeDelay));this.playerMonitor.rebind();await this.playButtonService.smartClick();}playNextLesson() {const position = this.courseDataProvider.getCurrentPosition(this.courseDataProvider.parse());if (!position?.next) {Logger.info("所有课程已播放完毕");return;}const { chapter, lessonIndex, lesson } = position.next;Logger.info(`播放下一节: 第${chapter.index}章 第${lessonIndex + 1}节 - ${lesson.name}`);const nameElement = lesson.element.querySelector(SELECTORS.course.lessonClickable);if (nameElement && DOMUtils.safeClick(nameElement)) {Logger.success(`已点击小节: ${lesson.name}`);} else {Logger.error("点击小节失败");}}}const controller = new AutoPlayController();async function bootstrap() {try {await controller.initialize();} catch (error) {Logger.error("插件初始化失败", error);}}if (document.readyState === "loading") {document.addEventListener("DOMContentLoaded", bootstrap);} else {bootstrap();}})();
该脚本作用
在 AboutCG 播放页(
/play*)自动播放视频,并在当前小节播放完后自动切到下一小节继续播放。监听课程目录变化与播放器状态,稳健地重绑定监听器,避免因 DOM 变动导致失效。
输出清晰的日志,便于观察"当前/下一节"等播放状态。
关键设计与流程
选择器与配置
SELECTORS:集中定义播放器控件与课程目录的 CSS 选择器(播放/暂停按钮、时间、章节/小节节点等)。CONFIG:集中管理时间阈值与容错策略(元素等待超时、重绑定间隔、播放结束容差、课节切换延迟等)。启动流程
等待目录与播放器控件出现;
解析并打印课程结构;
注册两个回调:播放结束与课节切换;
尝试点击封面播放/控制栏按钮开始播放(
smartClick())。bootstrap():在DOMContentLoaded后启动。AutoPlayController.initialize():播放与切课
发现"正在播放"标记从一个小节转移到另一个小节 → 触发
onLessonChange回调;回调中延迟、重绑定监听并再次点击播放。
当
current ≈ duration(容差1秒)→ 认定播放结束 → 延迟后调用playNextLesson()。PlayerMonitorService监听当前时间/总时长与按钮状态:CourseObserverService监听目录 DOM 变化:
主要方法
DOMUtils(DOM 工具)
waitForElement(selector, timeout):稳态等待元素出现(需要连续检测到同一个节点CONFIG.stability.checks次),并带总超时。isElementVisible(element):判定可见。safeClick(element):"可见才点"的安全点击。getTextContent()/queryText():获取去空白文本。
Logger(日志)
info/success/error/warn/debug/special():带统一前缀与表情的日志输出。
CourseDataProvider(课程数据解析)
parse(forceRefresh):扫描目录,产出结构体:{chapters: [{ index, name, duration, lessons: [{ name, duration, isPlaying, index, element } ...] }],currentLesson: { chapterIndex, lessonIndex, lessonName } // 若能识别}getCurrentPosition(courseData):基于当前小节定位前/当前/后位置,方便切换。getNextLesson()/getPreviousLesson():便捷获取下一/上一节。logCourseData():打印章节与小节明细及当前定位。
CourseObserverService(目录观察)
start()/stop():以MutationObserver监听目录区域(class/节点变动)。handleCourseMenuChange():若"正在播放"小节发生变化 → 调callbacks.onLessonChange(new, old)。findCurrentPlayingLesson():在解析数据中找带"正在播放"标识的小节。
PlayButtonService(播放点击策略)
smartClick():优先点封面大播放按钮;
再点控制栏播放按钮(仅当按钮是"播放"态类名)。
isPlaying():判断控制键是否处于"暂停按钮"态(即正在播放)。
TimeUtils(时间工具)
parseTimeToSeconds("mm:ss" | "hh:mm:ss"):文本转秒。formatSeconds(sec):秒转标准时间字符串。calculatePercentage(cur, total):进度百分比(字符串保留两位)。isTimeClose(a,b,tolerance):容差判断。
PlayerMonitorService(播放器观察)
start()/stop():绑定/解绑所有观察器,并开启周期性重绑定检查。bindObservers():监听 当前时间节点 的文本变化(时间推进);
监听 播放键 class 变化(播放/暂停切换)。
handleTimeUpdate():记录并打印
current / duration与百分比;若接近结束(容差
CONFIG.playEnd.tolerance秒) → 触发onPlayEnd();若之后时间被重置回头(小于
duration - resetThreshold)→ 清除"已结束"标记。handleStateChange():输出暂停/播放状态变化。startContainerObserver():若播放器内部重新渲染导致时间节点替换 → 触发bindObservers()。startPeriodicCheck():每隔CONFIG.monitor.rebindIntervalms 检测是否需要重绑定。rebind()/cleanup():重置并重新挂钩观察器。
AutoPlayController(总控)
initialize():如上启动逻辑。startAutoPlay():启动PlayerMonitorService,必要时smartClick()。handlePlayEnd():延迟后playNextLesson()。handleLessonChange(new, old):延迟、重绑、smartClick()。playNextLesson():用CourseDataProvider.getCurrentPosition()找 下一节 并点击其可点击元素(lessonClickable),失败则报错。
稳健细节
稳态元素检测:
waitForElement要求同一元素出现多次才认定"稳定",减少误判。多重监听:时间文本、播放键 class、播放器容器与目录区 DOM 全量覆盖,DOM 重绘也能自愈。
容差与阈值:播放结束容差 1s、重置阈值 5s、切课/点击延时参数可统一调参。
日志详尽,便于排错与观测行为。
注意:
本文部分变量已做脱敏处理,仅用于测试和学习研究,禁止用于商业用途,不能保证其合法性,准确性,完整性和有效性,请根据情况自行判断。技术层面需要提供帮助,可以通过打赏的方式进行探讨。
没有评论:
发表评论