From 9746b87f6fcab6096908c0a2d8a619fd959e0bbb Mon Sep 17 00:00:00 2001 From: Yeonjin Kim Date: Thu, 2 Jul 2026 01:11:20 +0900 Subject: [PATCH 1/6] =?UTF-8?q?eCampus=20=EC=88=98=EC=A7=91=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EB=8C=80=EC=9D=91=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/page-dev-bridge.js | 8 + src/content/bootstrap.ts | 83 +++- src/content/index.ts | 1 + src/content/modules/crawler.course-items.ts | 66 ++- src/content/modules/moodle.api.ts | 400 ++++++++++++++++++ src/content/modules/shared.ts | 1 + src/content/ui/components/DashboardApp.tsx | 4 + src/content/ui/components/DashboardShell.tsx | 41 +- .../dashboardShell/DashboardFooter.tsx | 37 +- .../dashboardShell/DashboardSettingsModal.tsx | 10 + .../settings/DashboardMoodleAuthSection.tsx | 130 ++++++ .../ui/components/dashboardShell/types.ts | 2 + src/content/ui/constants.ts | 1 + src/content/ui/runtime/dashboardDevTools.ts | 20 + src/content/ui/runtime/dashboardRuntimeApi.ts | 7 + .../ui/runtime/dashboardRuntimeSetup.ts | 3 + src/content/ui/types.ts | 1 + 17 files changed, 800 insertions(+), 15 deletions(-) create mode 100644 src/content/modules/moodle.api.ts create mode 100644 src/content/ui/components/dashboardShell/settings/DashboardMoodleAuthSection.tsx diff --git a/public/page-dev-bridge.js b/public/page-dev-bridge.js index 745e2d9..33c3443 100644 --- a/public/page-dev-bridge.js +++ b/public/page-dev-bridge.js @@ -137,4 +137,12 @@ devApi.openSettings = function openSettings() { dispatchDevCommand('open-settings'); }; + + devApi.previewErrorLog = function previewErrorLog(text) { + dispatchDevCommand('preview-error-log', { text }); + }; + + devApi.clearErrorLog = function clearErrorLog() { + dispatchDevCommand('clear-error-log'); + }; })(); diff --git a/src/content/bootstrap.ts b/src/content/bootstrap.ts index e34f5a4..3288ede 100644 --- a/src/content/bootstrap.ts +++ b/src/content/bootstrap.ts @@ -224,6 +224,39 @@ })); } + function errorMessage(err) { + return E.cleanText(err?.stack || err?.message || err || 'unknown error'); + } + + function buildFailureLog(failures) { + const rows = Array.isArray(failures) ? failures : []; + if (!rows.length) return ''; + + return [ + '[eHelper] eCampus 수집 실패 로그', + `time=${new Date().toLocaleString()}`, + `url=${location.href}`, + `failures=${rows.length}`, + '', + ...rows.map((failure, index) => { + const course = failure?.course || {}; + const html = String( + E.__lastCourseCrawlHtml?.[String(course.courseId)] || '', + ).trim(); + return [ + `#${index + 1}`, + `courseId=${course.courseId || '-'}`, + `courseName=${course.courseName || '-'}`, + `reason=${failure?.reason || '-'}`, + `message=${failure?.message || '-'}`, + html + ? `html=\n----- HTML START -----\n${html}\n----- HTML END -----` + : 'html=-', + ].join('\n'); + }), + ].join('\n'); + } + async function crawlCoursesWithResults(courses) { if (typeof E.crawlCoursesWithConcurrency === 'function') { const results = await E.crawlCoursesWithConcurrency(courses); @@ -235,9 +268,20 @@ E.constants.CRAWL_CONCURRENCY, async (course) => { try { + const items = await E.crawlCourseItems(course); + if (!Array.isArray(items) || !items.length) { + return { + course, + items: [], + ok: false, + reason: 'empty_items', + message: '과목 수집 결과가 비어 있음', + }; + } + return { course, - items: await E.crawlCourseItems(course), + items, ok: true, }; } catch (err) { @@ -249,6 +293,8 @@ course, items: [], ok: false, + reason: 'exception', + message: errorMessage(err), }; } }, @@ -312,6 +358,7 @@ reusedCourseCount: 0, crawledCourseCount: 0, failedCourseCount: 0, + failures: [], }; } @@ -354,6 +401,7 @@ const crawledItems = []; let failedCourseCount = 0; + const failures = []; if (coursesToCrawl.length) { const results = await crawlCoursesWithResults(coursesToCrawl); @@ -367,8 +415,20 @@ if (!courseId) return; handledCourseIds.add(courseId); - if (result?.ok === false) { + const resultItems = Array.isArray(result?.items) + ? result.items + : []; + if (result?.ok === false || !resultItems.length) { failedCourseCount += 1; + failures.push({ + course, + reason: result.reason || 'crawl_failed', + message: + result.message || + (!resultItems.length + ? '과목 수집 결과가 비어 있음' + : '과목 수집 실패'), + }); crawledItems.push( ...bindItemsToCourse( snapshotItemsByCourse.get(courseId), @@ -379,7 +439,7 @@ } nextRunMap[courseId] = now; - crawledItems.push(...bindItemsToCourse(result?.items, course)); + crawledItems.push(...bindItemsToCourse(resultItems, course)); }); coursesToCrawl.forEach((course) => { @@ -387,6 +447,11 @@ if (!courseId || handledCourseIds.has(courseId)) return; failedCourseCount += 1; + failures.push({ + course, + reason: 'missing_result', + message: '과목 수집 결과 누락', + }); crawledItems.push( ...bindItemsToCourse(snapshotItemsByCourse.get(courseId), course), ); @@ -402,6 +467,7 @@ reusedCourseCount, crawledCourseCount: coursesToCrawl.length, failedCourseCount, + failures, }; } @@ -541,6 +607,7 @@ ? 'CACHE' : 'OK', ); + E.setErrorLog?.(buildFailureLog(refreshed.failures)); E.setSub(summarizeRefreshOutcome(visibleItems, refreshed)); E.render(visibleItems); return; @@ -678,11 +745,21 @@ ? 'CACHE' : 'OK', ); + E.setErrorLog?.(buildFailureLog(refreshed.failures)); E.setSub(summarizeRefreshOutcome(mergedItems, refreshed)); E.render(mergedItems); } catch (e) { console.error(e); E.setBadge('ERR'); + E.setErrorLog?.( + buildFailureLog([ + { + course: getCurrentCourse() || {}, + reason: 'refresh_exception', + message: errorMessage(e), + }, + ]), + ); E.setSub( '크롤링 중 오류가 발생했어요. (로그인 상태/권한/네트워크 확인)', ); diff --git a/src/content/index.ts b/src/content/index.ts index 6c6a8c0..51fc93f 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -8,6 +8,7 @@ import './modules/shared'; import './modules/vod'; import './modules/vod.panel'; import './modules/crawler.course'; +import './modules/moodle.api'; import './modules/crawler'; import './modules/crawler.meta'; import './modules/crawler.activity'; diff --git a/src/content/modules/crawler.course-items.ts b/src/content/modules/crawler.course-items.ts index 014863c..79ef7df 100644 --- a/src/content/modules/crawler.course-items.ts +++ b/src/content/modules/crawler.course-items.ts @@ -24,6 +24,55 @@ 1, Number(E.constants?.DETAIL_ENRICH_CONCURRENCY || 2), ); + const crawlApiFallback = async () => { + if (typeof E.fetchMoodleCourseContents !== 'function') return []; + + const contents = await E.fetchMoodleCourseContents(courseId); + if (!Array.isArray(contents) || !contents.length) return []; + + const [assignments, quizzes, lectures, resources] = + await Promise.all([ + typeof E.fetchMoodleAssignmentItems === 'function' + ? E.fetchMoodleAssignmentItems({ + courseId, + courseName: normalizedCourseName, + courseIsNew: normalizedCourseIsNew, + }) + : Promise.resolve([]), + typeof E.fetchMoodleQuizItems === 'function' + ? E.fetchMoodleQuizItems({ + courseId, + courseName: normalizedCourseName, + courseIsNew: normalizedCourseIsNew, + contents, + }) + : Promise.resolve([]), + typeof E.fetchMoodleLectureItems === 'function' + ? E.fetchMoodleLectureItems({ + courseId, + courseName: normalizedCourseName, + courseIsNew: normalizedCourseIsNew, + contents, + }) + : Promise.resolve([]), + !shouldSkipResources && + typeof E.fetchMoodleResourceItems === 'function' + ? E.fetchMoodleResourceItems({ + courseId, + courseName: normalizedCourseName, + courseIsNew: normalizedCourseIsNew, + contents, + }) + : Promise.resolve([]), + ]); + + return E.dedupeItems([ + ...assignments, + ...quizzes, + ...lectures, + ...resources, + ]); + }; const crawlAssignmentItems = async () => { try { const assignHtml = await E.fetchHtml( @@ -130,6 +179,10 @@ const courseHtml = await E.fetchHtml( `/course/view.php?id=${courseId}`, ); + E.__lastCourseCrawlHtml = E.__lastCourseCrawlHtml || {}; + E.__lastCourseCrawlHtml[String(courseId)] = String( + courseHtml || '', + ).slice(0, 50000); courseDoc = new DOMParser().parseFromString( courseHtml, 'text/html', @@ -371,6 +424,17 @@ all.push(...assignmentItems, ...quizItems); - return E.dedupeItems(all); + const items = E.dedupeItems(all); + if (items.length) return items; + + try { + return await crawlApiFallback(); + } catch (err) { + console.debug( + `[ECDASH] Moodle API fallback failed. courseId=${courseId}`, + err, + ); + return []; + } }; })(); diff --git a/src/content/modules/moodle.api.ts b/src/content/modules/moodle.api.ts new file mode 100644 index 0000000..0c14990 --- /dev/null +++ b/src/content/modules/moodle.api.ts @@ -0,0 +1,400 @@ +// @ts-nocheck +(() => { + const E = window.__ECDASH__; + if (!E) return; + + const REST_ENDPOINT = 'https://ecampus.smu.ac.kr/webservice/rest/server.php'; + E.__missingMoodleFunctions = E.__missingMoodleFunctions || new Set(); + let availableFunctionsPromise = null; + let siteInfoPromise = null; + + async function getStoredMoodleToken() { + try { + const key = E.constants?.MOODLE_TOKEN_KEY || 'ecdash:smu:moodleToken'; + const res = await chrome.storage?.local?.get?.([key]); + return E.cleanText(res?.[key]); + } catch { + return ''; + } + } + + async function requestMoodleApi(wsfunction, params = {}) { + const token = await getStoredMoodleToken(); + if (!token) throw new Error('Moodle token missing'); + + const query = new URLSearchParams({ + wstoken: token, + wsfunction, + moodlewsrestformat: 'json', + }); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value == null) return; + query.set(key, String(value)); + }); + + const res = await fetch(`${REST_ENDPOINT}?${query.toString()}`, { + cache: 'no-store', + }); + if (!res.ok) throw new Error(`Moodle API failed ${res.status}`); + + const data = await res.json(); + if (data?.exception || data?.errorcode || data?.error) { + if ( + data?.errorcode === 'invalidrecord' && + /external_functions/i.test(E.cleanText(data?.message)) + ) { + E.__missingMoodleFunctions.add(wsfunction); + } + throw new Error(data.message || data.error || data.errorcode); + } + + return data; + } + + E.getMoodleSiteInfo = async function getMoodleSiteInfo() { + if (!siteInfoPromise) { + siteInfoPromise = requestMoodleApi( + 'core_webservice_get_site_info', + ).catch(() => null); + } + + return await siteInfoPromise; + }; + + E.getAvailableMoodleFunctions = async function getAvailableMoodleFunctions() { + if (!availableFunctionsPromise) { + availableFunctionsPromise = E.getMoodleSiteInfo() + .then((data) => { + const names = (Array.isArray(data?.functions) + ? data.functions + : [] + ) + .map((fn) => E.cleanText(fn?.name)) + .filter(Boolean); + return names.length ? new Set(names) : null; + }) + .catch(() => null); + } + + return await availableFunctionsPromise; + }; + + E.isMoodleFunctionAvailable = async function isMoodleFunctionAvailable( + wsfunction, + ) { + if (E.__missingMoodleFunctions.has(wsfunction)) return false; + + const available = await E.getAvailableMoodleFunctions(); + if (!available) return true; + + const ok = available.has(wsfunction); + if (!ok) E.__missingMoodleFunctions.add(wsfunction); + return ok; + }; + + E.callMoodleApi = async function callMoodleApi(wsfunction, params = {}) { + if (wsfunction !== 'core_webservice_get_site_info') { + const available = await E.isMoodleFunctionAvailable(wsfunction); + if (!available) { + throw new Error(`Moodle API function unavailable: ${wsfunction}`); + } + } + + return await requestMoodleApi(wsfunction, params); + }; + + async function applyMoodleCompletionStatus(_courseId, items) { + const source = Array.isArray(items) ? items : []; + return source.map((item) => { + const { __cmid, ...cleanItem } = item; + return cleanItem; + }); + }; + + E.fetchMoodleAssignmentItems = async function fetchMoodleAssignmentItems({ + courseId, + courseName, + courseIsNew = false, + statusConcurrency = 2, + }) { + const data = await E.callMoodleApi('mod_assign_get_assignments', { + 'courseids[0]': courseId, + }); + + const course = (data?.courses || []).find( + (it) => String(it?.id) === String(courseId), + ); + const assignments = Array.isArray(course?.assignments) + ? course.assignments + : []; + + const items = assignments + .map((assignment) => { + const title = E.cleanText(assignment?.name); + const cmid = assignment?.cmid; + if (!title || !cmid) return null; + + const url = new URL( + `/mod/assign/view.php?id=${cmid}`, + location.origin, + ).toString(); + const dueAt = Number(assignment?.duedate || 0) * 1000; + + return { + id: E.makeId('ASSIGNMENT', courseId, title, url), + type: 'ASSIGNMENT', + courseId, + courseName, + courseIsNew: Boolean(courseIsNew), + title, + url, + dueAt: dueAt > 0 ? dueAt : undefined, + dueScore: dueAt > 0 ? 3 : 0, + status: 'UNKNOWN', + meta: undefined, + __cmid: cmid, + }; + }) + .filter(Boolean); + + return await applyMoodleCompletionStatus(courseId, items); + }; + + E.fetchMoodleCourseContents = async function fetchMoodleCourseContents( + courseId, + ) { + const data = await E.callMoodleApi('core_course_get_contents', { + courseid: courseId, + }); + return Array.isArray(data) ? data : []; + }; + + function getModuleUrl(module) { + return E.normalizeUrl( + module?.url || + module?.contents?.[0]?.fileurl || + module?.contents?.[0]?.url || + '', + ); + } + + function getModuleCompletionStatus(module) { + const state = Number(module?.completiondata?.state); + if (!Number.isFinite(state)) return 'UNKNOWN'; + return state > 0 ? 'DONE' : 'TODO'; + } + + E.fetchMoodleResourceItems = async function fetchMoodleResourceItems({ + courseId, + courseName, + courseIsNew = false, + contents, + }) { + const sections = + contents || (await E.fetchMoodleCourseContents(courseId)); + const items = []; + + for (const section of sections) { + const sectionName = E.cleanText(section?.name) || undefined; + const modules = Array.isArray(section?.modules) + ? section.modules + : []; + + for (const module of modules) { + const modname = E.cleanText(module?.modname).toLowerCase(); + if ( + ![ + 'resource', + 'folder', + 'url', + 'page', + 'file', + 'ubfile', + ].includes(modname) + ) { + continue; + } + + const title = E.cleanText(module?.name); + const url = getModuleUrl(module); + if (!title || !url) continue; + + items.push({ + id: E.makeId('RESOURCE', courseId, title, url), + type: 'RESOURCE', + courseId, + courseName, + courseIsNew: Boolean(courseIsNew), + title, + url, + section: sectionName, + dueAt: undefined, + dueScore: 0, + status: getModuleCompletionStatus(module), + meta: E.cleanText(module?.modplural) || undefined, + __cmid: module.id, + }); + } + } + + return await applyMoodleCompletionStatus(courseId, items); + }; + + E.fetchMoodleLectureItems = async function fetchMoodleLectureItems({ + courseId, + courseName, + courseIsNew = false, + contents, + }) { + const sections = + contents || (await E.fetchMoodleCourseContents(courseId)); + const items = []; + + for (const section of sections) { + const sectionName = E.cleanText(section?.name) || undefined; + const modules = Array.isArray(section?.modules) + ? section.modules + : []; + + for (const module of modules) { + const modname = E.cleanText(module?.modname).toLowerCase(); + const title = E.cleanText(module?.name); + const url = getModuleUrl(module); + if (!title || !url) continue; + + const looksLikeLecture = + [ + 'vod', + 'video', + 'econtents', + 'ubonline', + 'ubvod', + 'contents', + ].includes(modname) || + /(vod|강의영상|동영상|온라인\s*강의|lecture|e-?contents)/i.test( + `${modname} ${title}`, + ); + if (!looksLikeLecture) continue; + + items.push({ + id: E.makeId('LECTURE', courseId, title, url), + type: 'LECTURE', + courseId, + courseName, + courseIsNew: Boolean(courseIsNew), + title, + url, + section: sectionName, + dueAt: undefined, + dueScore: 0, + status: 'UNKNOWN', + meta: E.cleanText(module?.modplural) || undefined, + __cmid: module.id, + }); + } + } + + return await applyMoodleCompletionStatus(courseId, items); + }; + + E.fetchMoodleNoticeItems = async function fetchMoodleNoticeItems({ + courseId, + courseName, + courseIsNew = false, + contents, + }) { + const sections = + contents || (await E.fetchMoodleCourseContents(courseId)); + const boards = []; + + for (const section of sections) { + const sectionName = E.cleanText(section?.name) || undefined; + const modules = Array.isArray(section?.modules) + ? section.modules + : []; + + for (const module of modules) { + const modname = E.cleanText(module?.modname).toLowerCase(); + const url = getModuleUrl(module); + if (modname !== 'ubboard' || !url) continue; + + boards.push({ + url, + boardTitle: E.cleanText(module?.name) || '공지사항', + section: sectionName, + }); + } + } + + if (!boards.length || typeof E.parseUbboardItemsFromHtml !== 'function') { + return []; + } + + const chunks = await E.mapWithConcurrency( + boards, + Math.min(2, boards.length), + async (board) => { + try { + const html = await E.fetchHtml(board.url); + return E.parseUbboardItemsFromHtml( + html, + courseId, + courseName, + courseIsNew, + board, + ); + } catch { + return []; + } + }, + ); + + return E.dedupeItems(chunks.flat()); + }; + + E.fetchMoodleQuizItems = async function fetchMoodleQuizItems({ + courseId, + courseName, + courseIsNew = false, + contents, + }) { + const sections = + contents || (await E.fetchMoodleCourseContents(courseId)); + const items = []; + + for (const section of sections) { + const sectionName = E.cleanText(section?.name) || undefined; + const modules = Array.isArray(section?.modules) + ? section.modules + : []; + + for (const module of modules) { + const modname = E.cleanText(module?.modname).toLowerCase(); + if (modname !== 'quiz') continue; + + const title = E.cleanText(module?.name); + const url = getModuleUrl(module); + if (!title || !url) continue; + + items.push({ + id: E.makeId('QUIZ', courseId, title, url), + type: 'QUIZ', + courseId, + courseName, + courseIsNew: Boolean(courseIsNew), + title, + url, + section: sectionName, + dueAt: undefined, + dueScore: 0, + status: getModuleCompletionStatus(module), + meta: undefined, + __cmid: module.id, + }); + } + } + + return await applyMoodleCompletionStatus(courseId, items); + }; +})(); diff --git a/src/content/modules/shared.ts b/src/content/modules/shared.ts index b71ab88..b7941ea 100644 --- a/src/content/modules/shared.ts +++ b/src/content/modules/shared.ts @@ -14,6 +14,7 @@ STORAGE_COURSES_KEY: `${storagePrefix}:courses`, STORAGE_COURSES_LAST_SYNC: `${storagePrefix}:courses:lastSyncAt`, STORAGE_COURSE_RUN_MAP: `${storagePrefix}:courses:lastRunMap`, + MOODLE_TOKEN_KEY: `${storagePrefix}:moodleToken`, CRAWL_CONCURRENCY: 3, DETAIL_ENRICH_CONCURRENCY: 2, REPORT_FETCH_CONCURRENCY: 2, diff --git a/src/content/ui/components/DashboardApp.tsx b/src/content/ui/components/DashboardApp.tsx index 8a62745..b348924 100644 --- a/src/content/ui/components/DashboardApp.tsx +++ b/src/content/ui/components/DashboardApp.tsx @@ -157,6 +157,7 @@ export function DashboardApp({ store, runtime }: DashboardAppProps) { courseFilterAllValue={COURSE_FILTER_ALL} settingsOpen={state.settingsOpen} contactLink={contactLink} + errorLog={state.errorLog} hidePastLectures={state.hidePastLectures} hidePastAssignments={state.hidePastAssignments} hidePastForums={state.hidePastForums} @@ -179,6 +180,9 @@ export function DashboardApp({ store, runtime }: DashboardAppProps) { onRefresh={() => { runtime.refreshAll?.({ force: true }); }} + onClearErrorLog={() => { + runtime.setErrorLog?.(''); + }} onOpenSettings={() => { if (Date.now() - lastSettingsCloseAtRef.current < 250) return; store.setState({ settingsOpen: true }); diff --git a/src/content/ui/components/DashboardShell.tsx b/src/content/ui/components/DashboardShell.tsx index e534247..f41389e 100644 --- a/src/content/ui/components/DashboardShell.tsx +++ b/src/content/ui/components/DashboardShell.tsx @@ -19,6 +19,7 @@ export function DashboardShell({ courseFilterAllValue, settingsOpen, contactLink, + errorLog, hidePastLectures, hidePastAssignments, hidePastForums, @@ -33,6 +34,7 @@ export function DashboardShell({ onFilterChange, onTypeFilterChange, onRefresh, + onClearErrorLog, onOpenSettings, onSelectCourse, onCloseSettings, @@ -97,6 +99,40 @@ export function DashboardShell({ onSelectCourse={onSelectCourse} /> + {errorLog && ( +
+
+ + 일부 수집에 실패했어요.

아래 + 문의하기를 통해 오류 로그를 보내주시면 +

+ 문제 해결에 큰 도움이 됩니다. +
+
+ + +
+
+
+ )} +
- + )} diff --git a/src/content/ui/components/dashboardShell/DashboardFooter.tsx b/src/content/ui/components/dashboardShell/DashboardFooter.tsx index 7027030..d9cd279 100644 --- a/src/content/ui/components/dashboardShell/DashboardFooter.tsx +++ b/src/content/ui/components/dashboardShell/DashboardFooter.tsx @@ -1,8 +1,12 @@ interface DashboardFooterProps { contactLink: string; + errorLog?: string; } -export function DashboardFooter({ contactLink }: DashboardFooterProps) { +export function DashboardFooter({ + contactLink, + errorLog, +}: DashboardFooterProps) { const currentYear = new Date().getFullYear(); return ( @@ -13,15 +17,28 @@ export function DashboardFooter({ contactLink }: DashboardFooterProps) { © {currentYear} Cotton. All rights reserved. - - 문의 - + + {errorLog && ( + + )} + + 문의 + + ); } diff --git a/src/content/ui/components/dashboardShell/DashboardSettingsModal.tsx b/src/content/ui/components/dashboardShell/DashboardSettingsModal.tsx index 746e900..2cb8742 100644 --- a/src/content/ui/components/dashboardShell/DashboardSettingsModal.tsx +++ b/src/content/ui/components/dashboardShell/DashboardSettingsModal.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import type { HiddenItemPreview } from './types'; import { DashboardFiltersSection } from './settings/DashboardFiltersSection'; import { DashboardHiddenItemsSection } from './settings/DashboardHiddenItemsSection'; +import { DashboardMoodleAuthSection } from './settings/DashboardMoodleAuthSection'; import { buildHiddenSummaryText, filterHiddenItems, @@ -57,12 +58,14 @@ export function DashboardSettingsModal({ onResetHiddenItems, }: DashboardSettingsModalProps) { const [hiddenSearch, setHiddenSearch] = useState(''); + const [moodleAuthOpen, setMoodleAuthOpen] = useState(true); const [filtersOpen, setFiltersOpen] = useState(true); const [hiddenItemsOpen, setHiddenItemsOpen] = useState(true); useEffect(() => { if (!visible) { setHiddenSearch(''); + setMoodleAuthOpen(true); setFiltersOpen(true); setHiddenItemsOpen(true); } @@ -146,6 +149,13 @@ export function DashboardSettingsModal({
+ { + setMoodleAuthOpen((prev) => !prev); + }} + /> + void; +} + +export function DashboardMoodleAuthSection({ + open, + onToggleOpen, +}: DashboardMoodleAuthSectionProps) { + const [connected, setConnected] = useState(false); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState(''); + + useEffect(() => { + if (!open) return; + + void chrome.storage.local + .get([MOODLE_TOKEN_KEY]) + .then((res: Record) => { + setConnected(Boolean(res?.[MOODLE_TOKEN_KEY])); + }); + }, [open]); + + const connect = async (event: FormEvent) => { + event.preventDefault(); + const formElement = event.currentTarget; + setLoading(true); + setMessage(''); + + const form = new FormData(formElement); + const username = String(form.get('username') || '').trim(); + const password = String(form.get('password') || ''); + + try { + if (!username || !password) { + throw new Error('학번과 비밀번호를 입력하세요'); + } + + const params = new URLSearchParams({ + username, + password, + service: 'moodle_mobile_app', + }); + const res = await fetch('https://ecampus.smu.ac.kr/login/token.php', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + cache: 'no-store', + }); + const data = await res.json(); + if (!data?.token) { + throw new Error(data?.error || '토큰 발급 실패'); + } + + await chrome.storage.local.set({ [MOODLE_TOKEN_KEY]: data.token }); + formElement.reset(); + setConnected(true); + setMessage('연결 완료'); + } catch (err) { + setMessage(err instanceof Error ? err.message : '연결 실패'); + } finally { + setLoading(false); + } + }; + + const disconnect = async () => { + await chrome.storage.local.remove([MOODLE_TOKEN_KEY]); + setConnected(false); + setMessage('연결 해제됨'); + }; + + return ( + +
+ + + +
+ + {connected ? ( + + ) : null} + + {message ? ( +

+ {message} +

+ ) : null} +
+ ); +} diff --git a/src/content/ui/components/dashboardShell/types.ts b/src/content/ui/components/dashboardShell/types.ts index afbb325..40b44b4 100644 --- a/src/content/ui/components/dashboardShell/types.ts +++ b/src/content/ui/components/dashboardShell/types.ts @@ -28,6 +28,7 @@ export interface DashboardShellProps { courseFilterAllValue: string; settingsOpen: boolean; contactLink: string; + errorLog: string; hidePastLectures: boolean; hidePastAssignments: boolean; hidePastForums: boolean; @@ -42,6 +43,7 @@ export interface DashboardShellProps { onFilterChange: (values: string[]) => void; onTypeFilterChange: (values: string[]) => void; onRefresh: () => void; + onClearErrorLog: () => void; onOpenSettings: () => void; onSelectCourse: (course: string) => void; onCloseSettings: () => void; diff --git a/src/content/ui/constants.ts b/src/content/ui/constants.ts index 80c1a9a..a9d2d65 100644 --- a/src/content/ui/constants.ts +++ b/src/content/ui/constants.ts @@ -16,4 +16,5 @@ export const UI_HIDE_NOTICES_KEY = `${UI_STORAGE_PREFIX}:hideNotices`; export const UI_INCLUDE_SM_CLASS_KEY = `${UI_STORAGE_PREFIX}:includeSmClass`; export const UI_HIDDEN_ITEM_IDS_KEY = `${UI_STORAGE_PREFIX}:hiddenItemIds`; export const UI_PANEL_POSITION_KEY = `${UI_STORAGE_PREFIX}:panelPosition`; +export const MOODLE_TOKEN_KEY = `${runtimeStoragePrefix}:moodleToken`; export const REPORT_EMAIL = 'kyj0719@gmail.com'; diff --git a/src/content/ui/runtime/dashboardDevTools.ts b/src/content/ui/runtime/dashboardDevTools.ts index c0b3e9f..04a2357 100644 --- a/src/content/ui/runtime/dashboardDevTools.ts +++ b/src/content/ui/runtime/dashboardDevTools.ts @@ -323,6 +323,26 @@ export function attachDashboardDevTools({ devPanelOpen: true, }); break; + case 'preview-error-log': + runtime.setErrorLog?.( + String( + detail.text || + `[eHelper] eCampus 수집 실패 로그 +time=${new Date().toLocaleString()} +url=${location.href} +failures=1 + +#1 +courseId=96598 +courseName=커리어디자인 +reason=empty_items +message=과목 수집 결과가 비어 있음`, + ), + ); + break; + case 'clear-error-log': + runtime.setErrorLog?.(''); + break; default: break; } diff --git a/src/content/ui/runtime/dashboardRuntimeApi.ts b/src/content/ui/runtime/dashboardRuntimeApi.ts index 165d0e7..1ed940e 100644 --- a/src/content/ui/runtime/dashboardRuntimeApi.ts +++ b/src/content/ui/runtime/dashboardRuntimeApi.ts @@ -41,6 +41,13 @@ export function attachDashboardRuntimeApi({ store.setState({ sub: next }); }; + runtime.setErrorLog = function setErrorLog(text: string) { + mountReactRoot(); + const next = String(text || '').trim(); + runtime.__lastErrorLog = next; + store.setState({ errorLog: next }); + }; + runtime.setLoading = function setLoading( isLoading: boolean, message?: string, diff --git a/src/content/ui/runtime/dashboardRuntimeSetup.ts b/src/content/ui/runtime/dashboardRuntimeSetup.ts index 27af86e..1a5e881 100644 --- a/src/content/ui/runtime/dashboardRuntimeSetup.ts +++ b/src/content/ui/runtime/dashboardRuntimeSetup.ts @@ -37,6 +37,7 @@ export function initializeRuntimeState(runtime: DashboardRuntime) { runtime.__hiddenItemIds = normalizeHiddenItemIds(runtime.__hiddenItemIds); runtime.__lastBadge = cleanText(runtime.__lastBadge || ''); runtime.__lastSub = cleanText(runtime.__lastSub || ''); + runtime.__lastErrorLog = String(runtime.__lastErrorLog || '').trim(); } // 런타임 값을 기준으로 UIStore의 초기 상태를 생성한다. @@ -62,6 +63,7 @@ export function createUiStore(runtime: DashboardRuntime) { loadingMessage: runtime.__loadingMessage || '데이터를 가져오는 중...', badge: runtime.__lastBadge || '', sub: runtime.__lastSub || '대시보드에서 과목을 찾고 활동을 크롤링해요.', + errorLog: String(runtime.__lastErrorLog || '').trim(), settingsOpen: false, devPanelOpen: false, devDataSource: 'real', @@ -156,5 +158,6 @@ export function syncStoreFromRuntime( hideNotices: Boolean(runtime.__hideNotices), includeSmClass: Boolean(runtime.__includeSmClass), hiddenItemIds: normalizeHiddenItemIds(runtime.__hiddenItemIds), + errorLog: String(runtime.__lastErrorLog || '').trim(), }); } diff --git a/src/content/ui/types.ts b/src/content/ui/types.ts index 0dc748c..728175d 100644 --- a/src/content/ui/types.ts +++ b/src/content/ui/types.ts @@ -44,6 +44,7 @@ export interface UiState { loadingMessage: string; badge: string; sub: string; + errorLog: string; settingsOpen: boolean; devPanelOpen: boolean; devDataSource: DashboardDevDataSource; From 1e3ac757acda78d08f382d64ed7db96fb598d7ae Mon Sep 17 00:00:00 2001 From: Yeonjin Kim Date: Thu, 2 Jul 2026 01:16:22 +0900 Subject: [PATCH 2/6] =?UTF-8?q?API=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/content/bootstrap.ts | 9 +++++++- src/content/modules/crawler.course-items.ts | 6 +++++ src/content/modules/moodle.api.ts | 5 ++++- src/content/ui/components/DashboardShell.tsx | 22 +++++++++++++++---- .../settings/DashboardMoodleAuthSection.tsx | 2 +- 5 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/content/bootstrap.ts b/src/content/bootstrap.ts index 3288ede..cf08464 100644 --- a/src/content/bootstrap.ts +++ b/src/content/bootstrap.ts @@ -419,12 +419,19 @@ ? result.items : []; if (result?.ok === false || !resultItems.length) { + const failureReason = + E.__lastCourseCrawlFailureReason?.[courseId] || + result.reason || + 'crawl_failed'; failedCourseCount += 1; failures.push({ course, - reason: result.reason || 'crawl_failed', + reason: failureReason, message: result.message || + (failureReason === 'moodle_token_missing' + ? '설정에서 eCampus API 로그인이 필요함' + : '') || (!resultItems.length ? '과목 수집 결과가 비어 있음' : '과목 수집 실패'), diff --git a/src/content/modules/crawler.course-items.ts b/src/content/modules/crawler.course-items.ts index 79ef7df..9f53b7b 100644 --- a/src/content/modules/crawler.course-items.ts +++ b/src/content/modules/crawler.course-items.ts @@ -430,6 +430,12 @@ try { return await crawlApiFallback(); } catch (err) { + if (/Moodle token missing/i.test(String(err?.message || err))) { + E.__lastCourseCrawlFailureReason = + E.__lastCourseCrawlFailureReason || {}; + E.__lastCourseCrawlFailureReason[String(courseId)] = + 'moodle_token_missing'; + } console.debug( `[ECDASH] Moodle API fallback failed. courseId=${courseId}`, err, diff --git a/src/content/modules/moodle.api.ts b/src/content/modules/moodle.api.ts index 0c14990..55ee818 100644 --- a/src/content/modules/moodle.api.ts +++ b/src/content/modules/moodle.api.ts @@ -20,7 +20,10 @@ async function requestMoodleApi(wsfunction, params = {}) { const token = await getStoredMoodleToken(); - if (!token) throw new Error('Moodle token missing'); + if (!token) { + E.__moodleTokenMissing = true; + throw new Error('Moodle token missing'); + } const query = new URLSearchParams({ wstoken: token, diff --git a/src/content/ui/components/DashboardShell.tsx b/src/content/ui/components/DashboardShell.tsx index f41389e..3eaf338 100644 --- a/src/content/ui/components/DashboardShell.tsx +++ b/src/content/ui/components/DashboardShell.tsx @@ -53,6 +53,7 @@ export function DashboardShell({ const settingsVisible = settingsOpen && !collapsed && isDashboardPage; const { panelRef, position, dragging, handlePointerDown } = useDashboardFloatingPosition(collapsed); + const isMoodleTokenMissing = /moodle_token_missing/.test(errorLog); return (
- 일부 수집에 실패했어요.

아래 - 문의하기를 통해 오류 로그를 보내주시면 -

- 문제 해결에 큰 도움이 됩니다. + {isMoodleTokenMissing ? ( + <> + 설정에서 eCampus API 로그인을 해주세요. +
+ 처음 연결 후 새로고침하면 eCampus + 로그인이 풀릴 수 있어요. +
+ 다시 로그인하고 사용하면 됩니다. + + ) : ( + <> + 일부 수집에 실패했어요.
아래 + 문의하기를 통해 오류 로그를 보내주시면 +
+ 문제 해결에 큰 도움이 됩니다. + + )}
+ {copyableErrorLog && ( + + )}
)} From 4a32265f5f75d7903adb05489b4a201e36de5bd9 Mon Sep 17 00:00:00 2001 From: Yeonjin Kim Date: Thu, 2 Jul 2026 01:22:21 +0900 Subject: [PATCH 4/6] =?UTF-8?q?=EC=88=98=EC=A7=91=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EA=B8=B0=EC=A4=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/content/bootstrap.ts | 48 ++++++++--------- src/content/modules/crawler.course-items.ts | 59 +++++++++++++++++++-- src/content/ui/runtime/dashboardDevTools.ts | 9 +++- 3 files changed, 83 insertions(+), 33 deletions(-) diff --git a/src/content/bootstrap.ts b/src/content/bootstrap.ts index cf08464..2f4ad1b 100644 --- a/src/content/bootstrap.ts +++ b/src/content/bootstrap.ts @@ -240,18 +240,24 @@ '', ...rows.map((failure, index) => { const course = failure?.course || {}; - const html = String( - E.__lastCourseCrawlHtml?.[String(course.courseId)] || '', - ).trim(); + const snippets = Array.isArray( + E.__lastCourseCrawlHtml?.[String(course.courseId)], + ) + ? E.__lastCourseCrawlHtml[String(course.courseId)] + : []; + const html = snippets + .map( + (snippet) => + `kind=${snippet.kind || '-'}\n----- HTML START -----\n${String(snippet.html || '').trim()}\n----- HTML END -----`, + ) + .join('\n\n'); return [ `#${index + 1}`, `courseId=${course.courseId || '-'}`, `courseName=${course.courseName || '-'}`, `reason=${failure?.reason || '-'}`, `message=${failure?.message || '-'}`, - html - ? `html=\n----- HTML START -----\n${html}\n----- HTML END -----` - : 'html=-', + html ? `html=\n${html}` : 'html=-', ].join('\n'); }), ].join('\n'); @@ -269,19 +275,9 @@ async (course) => { try { const items = await E.crawlCourseItems(course); - if (!Array.isArray(items) || !items.length) { - return { - course, - items: [], - ok: false, - reason: 'empty_items', - message: '과목 수집 결과가 비어 있음', - }; - } - return { course, - items, + items: Array.isArray(items) ? items : [], ok: true, }; } catch (err) { @@ -418,23 +414,23 @@ const resultItems = Array.isArray(result?.items) ? result.items : []; - if (result?.ok === false || !resultItems.length) { - const failureReason = - E.__lastCourseCrawlFailureReason?.[courseId] || - result.reason || - 'crawl_failed'; + const failureReason = + E.__lastCourseCrawlFailureReason?.[courseId] || + result.reason || + ''; + if (result?.ok === false || failureReason) { failedCourseCount += 1; failures.push({ course, - reason: failureReason, + reason: failureReason || 'crawl_failed', message: result.message || (failureReason === 'moodle_token_missing' ? '설정에서 eCampus API 로그인이 필요함' + : failureReason === 'course_html_failed' + ? '과목 활동 HTML을 불러오지 못함' : '') || - (!resultItems.length - ? '과목 수집 결과가 비어 있음' - : '과목 수집 실패'), + '과목 수집 실패', }); crawledItems.push( ...bindItemsToCourse( diff --git a/src/content/modules/crawler.course-items.ts b/src/content/modules/crawler.course-items.ts index 9f53b7b..f2807ed 100644 --- a/src/content/modules/crawler.course-items.ts +++ b/src/content/modules/crawler.course-items.ts @@ -24,6 +24,29 @@ 1, Number(E.constants?.DETAIL_ENRICH_CONCURRENCY || 2), ); + E.__lastCourseCrawlHtml = E.__lastCourseCrawlHtml || {}; + E.__lastCourseCrawlFailureReason = + E.__lastCourseCrawlFailureReason || {}; + E.__lastCourseCrawlHtml[String(courseId)] = []; + delete E.__lastCourseCrawlFailureReason[String(courseId)]; + let courseHubFailed = false; + const saveHtmlSnippet = (kind, html, selectors = []) => { + E.__lastCourseCrawlHtml = E.__lastCourseCrawlHtml || {}; + const key = String(courseId); + const snippets = (E.__lastCourseCrawlHtml[key] = + E.__lastCourseCrawlHtml[key] || []); + const doc = new DOMParser().parseFromString( + String(html || ''), + 'text/html', + ); + const node = selectors + .map((selector) => doc.querySelector(selector)) + .find(Boolean); + snippets.push({ + kind, + html: String(node?.outerHTML || html || '').slice(0, 20000), + }); + }; const crawlApiFallback = async () => { if (typeof E.fetchMoodleCourseContents !== 'function') return []; @@ -78,6 +101,11 @@ const assignHtml = await E.fetchHtml( `/mod/assign/index.php?id=${courseId}`, ); + saveHtmlSnippet('assignment:index', assignHtml, [ + 'table.generaltable', + '#region-main', + '[role="main"]', + ]); const assignItems = E.parseAssignIndexHtml( assignHtml, courseId, @@ -125,6 +153,11 @@ const quizHtml = await E.fetchHtml( `/mod/quiz/index.php?id=${courseId}`, ); + saveHtmlSnippet('quiz:index', quizHtml, [ + 'table.generaltable', + '#region-main', + '[role="main"]', + ]); const quizItems = E.parseQuizIndexHtml( quizHtml, courseId, @@ -179,10 +212,11 @@ const courseHtml = await E.fetchHtml( `/course/view.php?id=${courseId}`, ); - E.__lastCourseCrawlHtml = E.__lastCourseCrawlHtml || {}; - E.__lastCourseCrawlHtml[String(courseId)] = String( - courseHtml || '', - ).slice(0, 50000); + saveHtmlSnippet('course:activities', courseHtml, [ + '.course-content', + '#region-main', + '[role="main"]', + ]); courseDoc = new DOMParser().parseFromString( courseHtml, 'text/html', @@ -256,6 +290,11 @@ async (reportUrl) => { try { const reportHtml = await E.fetchHtml(reportUrl); + saveHtmlSnippet('lecture:report', reportHtml, [ + 'table', + '#region-main', + '[role="main"]', + ]); const reportMap = E.parseStatusRowsFromReportHtml(reportHtml); const rowsCount = Array.isArray(reportMap.rows) @@ -411,6 +450,11 @@ ...noticeItems, ); } catch (err) { + courseHubFailed = true; + E.__lastCourseCrawlFailureReason = + E.__lastCourseCrawlFailureReason || {}; + E.__lastCourseCrawlFailureReason[String(courseId)] = + 'course_html_failed'; console.warn( `[ECDASH] course hub crawl failed. courseId=${courseId} (${normalizedCourseName})`, err, @@ -426,9 +470,14 @@ const items = E.dedupeItems(all); if (items.length) return items; + if (!courseHubFailed) return []; try { - return await crawlApiFallback(); + const apiItems = await crawlApiFallback(); + if (apiItems.length) { + delete E.__lastCourseCrawlFailureReason[String(courseId)]; + } + return apiItems; } catch (err) { if (/Moodle token missing/i.test(String(err?.message || err))) { E.__lastCourseCrawlFailureReason = diff --git a/src/content/ui/runtime/dashboardDevTools.ts b/src/content/ui/runtime/dashboardDevTools.ts index 04a2357..0d83f18 100644 --- a/src/content/ui/runtime/dashboardDevTools.ts +++ b/src/content/ui/runtime/dashboardDevTools.ts @@ -335,8 +335,13 @@ failures=1 #1 courseId=96598 courseName=커리어디자인 -reason=empty_items -message=과목 수집 결과가 비어 있음`, +reason=course_html_failed +message=과목 활동 HTML을 불러오지 못함 +html= +kind=assignment:index +----- HTML START ----- +
테스트 과제 HTML
+----- HTML END -----`, ), ); break; From e0abc0a004cb57a3997c5c7baf344f58f36ff786 Mon Sep 17 00:00:00 2001 From: Yeonjin Kim Date: Thu, 2 Jul 2026 01:35:23 +0900 Subject: [PATCH 5/6] =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EC=95=88=EC=A0=95?= =?UTF-8?q?=EC=84=B1=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/content/bootstrap.ts | 2 +- src/content/modules/crawler.course-items.ts | 6 ++++++ src/content/modules/moodle.api.ts | 1 - src/content/ui/components/DashboardShell.tsx | 2 +- .../components/dashboardShell/DashboardFooter.tsx | 4 +++- .../settings/DashboardMoodleAuthSection.tsx | 13 ++++++++++++- 6 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/content/bootstrap.ts b/src/content/bootstrap.ts index 2f4ad1b..8a215ca 100644 --- a/src/content/bootstrap.ts +++ b/src/content/bootstrap.ts @@ -225,7 +225,7 @@ } function errorMessage(err) { - return E.cleanText(err?.stack || err?.message || err || 'unknown error'); + return String(err?.stack || err?.message || err || 'unknown error').trim(); } function buildFailureLog(failures) { diff --git a/src/content/modules/crawler.course-items.ts b/src/content/modules/crawler.course-items.ts index f2807ed..f27e7e0 100644 --- a/src/content/modules/crawler.course-items.ts +++ b/src/content/modules/crawler.course-items.ts @@ -35,6 +35,12 @@ const key = String(courseId); const snippets = (E.__lastCourseCrawlHtml[key] = E.__lastCourseCrawlHtml[key] || []); + if ( + snippets.length >= 6 || + snippets.filter((snippet) => snippet?.kind === kind).length >= 2 + ) { + return; + } const doc = new DOMParser().parseFromString( String(html || ''), 'text/html', diff --git a/src/content/modules/moodle.api.ts b/src/content/modules/moodle.api.ts index 55ee818..4395f26 100644 --- a/src/content/modules/moodle.api.ts +++ b/src/content/modules/moodle.api.ts @@ -119,7 +119,6 @@ courseId, courseName, courseIsNew = false, - statusConcurrency = 2, }) { const data = await E.callMoodleApi('mod_assign_get_assignments', { 'courseids[0]': courseId, diff --git a/src/content/ui/components/DashboardShell.tsx b/src/content/ui/components/DashboardShell.tsx index 4deeaed..552c8e5 100644 --- a/src/content/ui/components/DashboardShell.tsx +++ b/src/content/ui/components/DashboardShell.tsx @@ -131,7 +131,7 @@ export function DashboardShell({ onClick={() => { void navigator.clipboard?.writeText( copyableErrorLog, - ); + )?.catch(() => {}); }} > 로그 복사 diff --git a/src/content/ui/components/dashboardShell/DashboardFooter.tsx b/src/content/ui/components/dashboardShell/DashboardFooter.tsx index d9cd279..322f459 100644 --- a/src/content/ui/components/dashboardShell/DashboardFooter.tsx +++ b/src/content/ui/components/dashboardShell/DashboardFooter.tsx @@ -23,7 +23,9 @@ export function DashboardFooter({ type="button" className="font-semibold text-red-600 bg-white transition hover:text-red-800 border-none" onClick={() => { - void navigator.clipboard?.writeText(errorLog); + void navigator.clipboard + ?.writeText(errorLog) + ?.catch(() => {}); }} > 오류 로그 복사 diff --git a/src/content/ui/components/dashboardShell/settings/DashboardMoodleAuthSection.tsx b/src/content/ui/components/dashboardShell/settings/DashboardMoodleAuthSection.tsx index 3a967a4..ef2af2c 100644 --- a/src/content/ui/components/dashboardShell/settings/DashboardMoodleAuthSection.tsx +++ b/src/content/ui/components/dashboardShell/settings/DashboardMoodleAuthSection.tsx @@ -22,6 +22,9 @@ export function DashboardMoodleAuthSection({ .get([MOODLE_TOKEN_KEY]) .then((res: Record) => { setConnected(Boolean(res?.[MOODLE_TOKEN_KEY])); + }) + .catch(() => { + setConnected(false); }); }, [open]); @@ -53,7 +56,15 @@ export function DashboardMoodleAuthSection({ body: params.toString(), cache: 'no-store', }); - const data = await res.json(); + let data: Record = {}; + try { + data = await res.json(); + } catch { + throw new Error('토큰 발급 실패'); + } + if (!res.ok) { + throw new Error(data?.error || '토큰 발급 실패'); + } if (!data?.token) { throw new Error(data?.error || '토큰 발급 실패'); } From 0c6f87176396930847dfdc8bf36ca6089402a89d Mon Sep 17 00:00:00 2001 From: Yeonjin Kim Date: Thu, 2 Jul 2026 01:39:23 +0900 Subject: [PATCH 6/6] =?UTF-8?q?=EB=B2=84=EC=A0=84=201.2.7=EB=A1=9C=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- manifest.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src/preview/landingContent.ts | 15 +++++++++++++++ 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/manifest.json b/manifest.json index 49de094..16756ec 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "eHelper", - "version": "1.2.6", + "version": "1.2.7", "description": "eCampus 과목별 과제/퀴즈/강의/토론/자료를 한눈에 보는 확장 프로그램", "permissions": ["storage", "downloads"], "host_permissions": [ diff --git a/package-lock.json b/package-lock.json index ab72f52..d16e93d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ecampus-dashboard-smu", - "version": "1.2.6", + "version": "1.2.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ecampus-dashboard-smu", - "version": "1.2.6", + "version": "1.2.7", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/package.json b/package.json index 238e05f..e0296a7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ecampus-dashboard-smu", - "version": "1.2.6", + "version": "1.2.7", "private": true, "type": "module", "scripts": { diff --git a/src/preview/landingContent.ts b/src/preview/landingContent.ts index ffa612d..f08c84d 100644 --- a/src/preview/landingContent.ts +++ b/src/preview/landingContent.ts @@ -91,6 +91,21 @@ export const previewFixHighlights: LandingFixHighlight[] = [ ]; export const previewReleaseNotes: LandingReleaseNote[] = [ + { + version: '1.2.7', + date: '2026-07-02', + label: '안정성', + title: '수집 실패 안내와 API fallback 개선', + summary: + 'HTML 파싱을 우선 유지하면서 실패 시 API fallback을 보조로 사용하고, 문제 제보용 오류 로그 복사 흐름을 추가했습니다.', + changes: [ + '과목 수집 실패 시 관련 활동 HTML 조각을 포함한 오류 로그 복사 기능 추가', + 'eCampus API 토큰 연결 UI와 토큰 미설정 안내 추가', + '결과가 비어 있는 정상 과목은 실패로 처리하지 않도록 실패 판정 기준 조정', + ], + benefit: + '평소 속도는 유지하면서 파싱 실패 상황을 더 쉽게 확인하고 제보할 수 있습니다.', + }, { version: '1.2.6', date: '2026-04-17',