diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..62742c4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +* text=auto eol=lf +*.bat text eol=crlf + +*.jar binary +*.png binary +*.riv binary diff --git a/.gitignore b/.gitignore index e1adb66..e67bc2d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,14 @@ captures/ .jdk*/ .codex-tmp/ +# Local reverse-engineering and research caches +/apk_unpack_*/ +/analysis/schedule-ui-import-fixes-*/ +/analysis/unpacked-adapter-audit-*/ +/analysis/ziyan-url-recovery-*/*.json +!/analysis/ziyan-url-recovery-*/verified_urls.json +/analysis/ziyan-url-recovery-*/ricraft-wakeup-schedule/ + # Signing credentials and environment files *.jks *.keystore diff --git a/CHANGELOG.md b/CHANGELOG.md index a86cd6a..6fe1f72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ 本项目遵循语义化版本的基本约定。 +## [未发布] + +### 新增 + +- 课表空白处可直接新建课程,并自动带入所点的星期和节次。 +- 默认节次时间调整为 08:00、08:50、09:50、10:40、11:30、14:30、15:20、16:20、17:10、19:00、19:50、20:40。 +- 同一时段重叠时保留“非本周”课程,并优先显示本周课程;非本周课程按距离当前周的远近确定层级。 +- 南航本科改用 EAMS `table0 + unitCount` 读取;新版正方增加课表 JSON 读取;强智增加常见表格变体识别。 +- 金智在已验证的 `wdkb` 流程之后,依次尝试本科 `xkjglapp`、研究生 `gsapp` 和移动端 `homeapp` 课表接口。 +- 教务导入允许 CAS、SSO、WebVPN 和厂商 HTTP/HTTPS 跨域跳转;特殊协议在确认后交给 Android,学校证书异常时仍需明确确认。 +- 移植并改写 WakeUp 的分学校操作指引(约 30 所学校的课表页导航步骤),在导入页展示。 +- 不接入智研(`ziyan`)云端解析;为其中 984 条复核候选入口并启用设备端实验解析,仍无可靠公网入口的 104 条保留为“需专用适配”。 +- 目录新增辽宁经济职业技术学院、沙洲职业工学院两所可通用导入的学校。 + +### 质量 + +- 修复目录学校名拼音搜索在 Android 10 以下因 Transliterator 规则缺失导致的 lint 报错。 +- 清理树维页面或学期切换时的旧响应缓存,避免误导入上一学期。 +- 增加 EAMS、正方 JSON、金智移动端、强智变体、周次层级和目录状态单元测试。 + ## [1.0.0] - 2026-08-04 ### 新增 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 862d3b5..f1cc1be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,12 +23,19 @@ Windows PowerShell 使用: .\gradlew.bat lintDebug testDebugUnitTest assembleDebug ``` +修改 WebView、导入流程或界面交互时,还应在已连接的模拟器或真机上执行: + +```powershell +.\gradlew.bat connectedDebugAndroidTest +``` + ## 分支与提交 - 从最新的 `main` 创建分支。 -- 建议使用 `feature/简短说明`、`fix/简短说明` 或 `docs/简短说明`。 +- 建议使用 `feat/简短说明`、`fix/简短说明` 或 `docs/简短说明`。 - 每个提交只处理一个清晰的问题,避免混入无关格式化或重构。 - 不要提交 `local.properties`、签名文件、真实课程表、个人截图或构建产物。 +- APK 解包、JADX 输出和临时克隆应放在项目目录之外;`analysis/` 只保留可复现脚本、脱敏的小型证据和结论。 ## 代码要求 diff --git a/README.md b/README.md index 1ab3ac9..6414901 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ - 左右跟手切换周次,每周保留独立的纵向滚动位置。 - 周视图展示课程名称、教师和完整地点,支持隐藏周末与调整课程块高度。 -- 手动添加、编辑和删除课程,支持单双周、课程颜色、备注和冲突检测。 +- 点击课表空白处可按对应星期和节次快速添加课程;也支持编辑、删除、单双周、课程颜色、备注和冲突检测。 - 导入 `.xlsx`、JSON、CSV、HTML 和纯文本,导入前检查无效项、重复项与时间冲突。 - 支持导入覆盖后的撤销,以及包含学期和显示设置的 JSON 完整备份。 - 管理学期起始日期、总周数和当前学期。 @@ -67,6 +67,9 @@ app/build/outputs/apk/debug/app-debug.apk 导入前会显示课程数量、冲突、重复、无效数据,以及地点和教师字段的完整度。 +- 教务目录收录 3,566 条本科、研究生和通用教务候选入口配置;其中 2,682 条可直接打开,另有 764 条已有本地解析路线、可填写学校官网确认的教务网址后试用,剩余 120 条仍需专用适配或可靠入口。“收录”不等于逐校适配或真机验证,目前只有西南石油大学经过用户真机验证。 +- 目录会区分“已验证 / 通用适配 / 实验识别 / 需专用适配”。应用不接入 `ziyan` 云端解析;其中 984 条经入口复核后改走设备上的实验性通用解析,剩余 104 条只显示说明与反馈入口,不要求用户手填网址。 +- 可尝试的入口会按教务类型走通用解析,并在导入预览中核对;解析不到明确的课程、星期、节次或周次时会中止,不会猜测导入。旧学校仅有 HTTP 时必须先确认明文风险。 - 旧版 `.xls` 暂不支持,请先在 Excel 中另存为 `.xlsx`。 - 应用只能导入源文件中实际存在的信息。若学校导出的文件没有教师姓名,导入预览会显示教师完整度为 `0/N`,可在课程详情中手动补充。 @@ -93,7 +96,7 @@ app/src/main/java/com/courseschedule/ ## 隐私与权限 -应用没有声明网络权限,也没有统计或广告 SDK。课程数据存储在本机 Room 数据库中;Android 系统备份可能按设备设置备份应用数据。权限用途和数据边界见 [隐私说明](docs/PRIVACY.md)。 +应用只在用户主动使用教务网页导入时访问学校网站,没有统计或广告 SDK。课程数据存储在本机 Room 数据库中;Android 系统备份可能按设备设置备份应用数据。权限用途和数据边界见 [隐私说明](docs/PRIVACY.md)。 ## 参与贡献 diff --git a/analysis/school-adapter-v2-20260909/README.md b/analysis/school-adapter-v2-20260909/README.md new file mode 100644 index 0000000..0fda112 --- /dev/null +++ b/analysis/school-adapter-v2-20260909/README.md @@ -0,0 +1,26 @@ +# School adapter v2 work area + +This directory contains only reproducible, public-endpoint audit tooling and +generated evidence summaries for the first local-parser expansion. It must not +contain credentials, cookies, authenticated timetable responses, or copied +third-party implementation code. + +The runtime application continues to parse timetable data on the device. Audit +results may promote a directory entry only to `experimental`; `verified` +requires an end-to-end check by a user of that school. + +Commands: + +```powershell +node analysis/school-adapter-v2-20260909/upgrade-directory-v3.mjs +node analysis/school-adapter-v2-20260909/audit-public-endpoints.mjs +node analysis/school-adapter-v2-20260909/validate-directory-security.mjs +``` + +The security check requires one cleartext-enabled base configuration and zero +domain rules, so HTTP schools and authentication redirects never depend on a +maintained host whitelist. + +The audit is deliberately non-promoting. A maintainer must add and review +school-official evidence before changing an `adapter_required` entry to +`experimental`. diff --git a/analysis/school-adapter-v2-20260909/audit-public-endpoints.mjs b/analysis/school-adapter-v2-20260909/audit-public-endpoints.mjs new file mode 100644 index 0000000..cc75936 --- /dev/null +++ b/analysis/school-adapter-v2-20260909/audit-public-endpoints.mjs @@ -0,0 +1,77 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import net from "node:net"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, "../.."); +const asset = path.join(root, "app/src/main/assets/academic_school_directory.json"); +const catalog = JSON.parse(fs.readFileSync(asset, "utf8")); +const firstPhaseSourceTypes = new Set(["zf", "qz", "jz", "kingo_new", "south_soft"]); +const selected = catalog.entries.filter((entry) => + entry.support === "adapter_required" && + firstPhaseSourceTypes.has(entry.sourceType || "") +); + +function classify(entry) { + const raw = entry.referenceUrl || entry.url || ""; + if (!raw) return { reason: "missing_reference", safeUrl: "" }; + let url; + try { url = new URL(raw); } catch { return { reason: "invalid_url", safeUrl: "" }; } + if (!["https:", "http:"].includes(url.protocol)) return { reason: "invalid_scheme", safeUrl: "" }; + if (net.isIP(url.hostname)) return { reason: "ip_literal", safeUrl: "" }; + if (url.username || url.password || url.search || url.hash) { + return { reason: "credentials_or_session_parameters", safeUrl: `${url.origin}${url.pathname}` }; + } + const pathName = url.pathname || "/"; + if (/vpn/i.test(url.hostname) && !/^\/(?:https?(?:-\d+)?)[/-][^/]+\//i.test(pathName)) { + return { reason: "vpn_portal_without_resource", safeUrl: `${url.origin}${pathName}` }; + } + const familyPattern = { + zf: /(?:jwglxt|xskbcx|xtgl\/login|default2?\.aspx)/i, + qz: /(?:jsxsd|kbtable|xskb|login\.do)/i, + jz: /(?:jwapp|gsapp|wdkb|xkjglapp|homeapp)/i, + kingo: /(?:pageRpt|reportArea|cas\/login\.action|jsxsd)/i, + south: /(?:gmis|stulogin|student.*schedule|kb)/i + }; + const family = entry.sourceType.startsWith("zf") ? "zf" : + entry.sourceType.startsWith("qz") ? "qz" : + entry.sourceType.startsWith("jz") ? "jz" : + /kingo|kg_zx|qingguo/.test(entry.sourceType) ? "kingo" : "south"; + if (!familyPattern[family].test(`${url.hostname}${pathName}`)) { + return { reason: "no_family_signature", safeUrl: `${url.origin}${pathName}` }; + } + return { + reason: url.protocol === "https:" ? "needs_official_source_evidence" : "http_needs_explicit_review", + safeUrl: `${url.origin}${pathName}`, + family + }; +} + +const rows = selected.map((entry) => ({ + id: entry.id, + name: entry.name, + sourceType: entry.sourceType, + adapterId: entry.adapterId, + ...classify(entry), + eligibleForPromotion: false +})); +const reasons = Object.fromEntries([...new Set(rows.map((row) => row.reason))] + .sort().map((reason) => [reason, rows.filter((row) => row.reason === reason).length])); +const report = { + catalogGeneratedAt: catalog.generatedAt || null, + directorySchemaVersion: catalog.schemaVersion, + scanned: rows.length, + promoted: 0, + policy: "Promotion requires a school-official public source, an explicit academic endpoint, safe redirect/domain review, and an observable family signature. Static upstream references alone never qualify.", + reasons, + entries: rows +}; +fs.writeFileSync(path.join(here, "endpoint-audit.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8"); +const markdown = `# 五大家族公开入口核验\n\n` + + `- 扫描条目:${rows.length}\n- 本次提升为 experimental:0\n` + + `- 结论:目录中这些 adapter_required 条目没有同时具备学校官方公开证据与可安全加载的明确入口,保持不可导入。\n\n` + + `## 原因统计\n\n` + Object.entries(reasons).map(([reason, count]) => `- ${reason}: ${count}`).join("\n") + + `\n\n完整逐条结果见 endpoint-audit.json。脚本不会下载或保存课表响应。\n`; +fs.writeFileSync(path.join(here, "endpoint-audit.md"), markdown, "utf8"); +console.log(`scanned=${rows.length} promoted=0`); diff --git a/analysis/school-adapter-v2-20260909/endpoint-audit.json b/analysis/school-adapter-v2-20260909/endpoint-audit.json new file mode 100644 index 0000000..26ad0ea --- /dev/null +++ b/analysis/school-adapter-v2-20260909/endpoint-audit.json @@ -0,0 +1,5218 @@ +{ + "catalogGeneratedAt": "2026-09-02", + "directorySchemaVersion": 3, + "scanned": 578, + "promoted": 0, + "policy": "Promotion requires a school-official public source, an explicit academic endpoint, safe redirect/domain review, and an observable family signature. Static upstream references alone never qualify.", + "reasons": { + "invalid_url": 15, + "ip_literal": 141, + "missing_reference": 357, + "no_family_signature": 1, + "vpn_portal_without_resource": 64 + }, + "entries": [ + { + "id": "wakeup_de36de46d32c451a", + "name": "金智教务", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_9906bff264be0c2c", + "name": "南软教务", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ab1d282accefd72f", + "name": "强智教务", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_1790df9cb3b18c6e", + "name": "新青果教学安排表", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_d83cd7465c82896a", + "name": "正方教务", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_9ddff5ad17452828", + "name": "安徽大学江淮学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_3ba4aee7229179f3", + "name": "安徽工程大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_5b552ca8dc007d47", + "name": "安徽工业大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_3a6632c0e6a9c2fa", + "name": "安徽建筑大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ca9991e6841f32ce", + "name": "安徽建筑大学城市建设学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_61294ea95e35306c", + "name": "安徽农业大学经济技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ec07af6a9549976e", + "name": "安徽卫生健康职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_0c57e45841d4ddaa", + "name": "安徽文达信息工程学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_fd95ab8856606294", + "name": "安徽新华学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://10-8-6-10.webvpn.axhu.edu.cn/kbcx/xskbcx_cxXskbcxIndex.html", + "eligibleForPromotion": false + }, + { + "id": "wakeup_e1ddba257f9a1b0f", + "name": "安徽医科大学临床医学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_9bd8e0e73ce06338", + "name": "安徽邮电职业技术学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_fa64aafd9ad61b6d", + "name": "安顺学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_88f46c478c7358b4", + "name": "蚌埠学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_dba93f7ad109a687", + "name": "蚌埠医学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_76ef364c631a0775", + "name": "包头师范学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_fea808a550ffe829", + "name": "保定学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "vpn_portal_without_resource", + "safeUrl": "http://webvpn.bdu.edu.cn/login", + "eligibleForPromotion": false + }, + { + "id": "wakeup_5a33125a71e5ba4c", + "name": "北部湾大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_9aed06bf19b54de8", + "name": "北华大学", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://10-2-0-102.sslvpn.beihua.edu.cn/jsxsd/", + "eligibleForPromotion": false + }, + { + "id": "wakeup_eb660a2d8e3b4238", + "name": "北华大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_b9b47246fb50cd3e", + "name": "北津学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_dd9a8aaa1bd83872", + "name": "北京城市学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://webvpn.bcu.edu.cn:8000/users/sign_in", + "eligibleForPromotion": false + }, + { + "id": "wakeup_cbb67ee419c304d4", + "name": "北京大学医学部 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_76435b1cca6ff323", + "name": "北京电子科技职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_bdfb7bf7d94527dd", + "name": "北京航空航天大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_e639c702ddd0703f", + "name": "北京化工大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_d36ea90f4ebed895", + "name": "北京化工大学昌平校区", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_a6531b8429cc18b0", + "name": "北京交通大学海滨学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_7c3553abd057290f", + "name": "北京科技大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_c17cb40c959c2ea5", + "name": "北京科技大学天津学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_4ff52b9957fbff72", + "name": "北京联合大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://wvpn.buu.edu.cn/login", + "eligibleForPromotion": false + }, + { + "id": "wakeup_c25d4bf5c0fb2618", + "name": "北京联合大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_27b5becff66ec414", + "name": "北京联合大学商务学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_fc7de965c1083e50", + "name": "北京联合大学应用文理学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_e2d7d4fa680fd596", + "name": "北京青年政治学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://vpn.bjypc.edu.cn/login", + "eligibleForPromotion": false + }, + { + "id": "wakeup_bf3eacae067600be", + "name": "北京社会管理职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_c90e21816603e1a2", + "name": "北京物资学院 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_e4e450b052f6ed73", + "name": "北京信息科技大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_85eaa753137bcc39", + "name": "北京信息职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://vpn.bitc.edu.cn/users/sign_in", + "eligibleForPromotion": false + }, + { + "id": "wakeup_08ce45536746e0b1", + "name": "北京印刷学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://webvpn.bupt.edu.cn/", + "eligibleForPromotion": false + }, + { + "id": "wakeup_defe0db56a47753d", + "name": "北京有色金属研究院 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_93eab54056dbd0b8", + "name": "北京语言大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://webvpn.blcu.edu.cn/users/sign_in", + "eligibleForPromotion": false + }, + { + "id": "wakeup_648a1d4e35d7ed95", + "name": "滨州科技职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_1d5584f01b31772f", + "name": "滨州医学院烟台校区", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_96cadc489fb14dd2", + "name": "渤海大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_069b146061e1cb40", + "name": "沧州交通学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://vpn.czjtu.edu.cn/users/sign_in", + "eligibleForPromotion": false + }, + { + "id": "wakeup_1f0b4e646d283bb2", + "name": "沧州交通学院 - 研究生", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_7443a4c276bb8e83", + "name": "曹妃甸职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_e0c1581ca07022fd", + "name": "常熟理工学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_7519ca4e22ae7003", + "name": "常熟理工学院(苏州常熟)", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_5360aef96c70c160", + "name": "常州大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_dc34711c41b4d40e", + "name": "常州轻工职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_921db63c6c3d70f1", + "name": "潮州卫生健康职业学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_bdd68fdb6f0de297", + "name": "成都工贸职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_e0e95906bdd2d03b", + "name": "成都体育学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://cas-cdsu-edu-cn-s.webvpn.cdsu.edu.cn:8118/cas/WEB/index.html", + "eligibleForPromotion": false + }, + { + "id": "wakeup_389beabc271b8c3c", + "name": "成都信息工程大学银杏酒店管理学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_5894a1025b45bea5", + "name": "成都学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_36effeebe6fdf891", + "name": "成都职业艺术大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_b60ee7abb47e168a", + "name": "承德石油高等专科学校", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_44f28d5d232161ed", + "name": "重庆传媒职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_a585204bc119683f", + "name": "重庆电子工程职业技术学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_138153b102b59569", + "name": "重庆工商大学融智学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_3f2ec9a3ecc378c2", + "name": "重庆建筑工程职业学院 - 研究生", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_a13b369e5cb04127", + "name": "重庆建筑科技职业学校", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_cb8df824b978beab", + "name": "重庆交通大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_73c863b84fd4e98d", + "name": "重庆交通职业学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_a80ea3a1896105ad", + "name": "重庆师范大学初等教育学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ec14497816b1f412", + "name": "重庆师范大学涉外商贸学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_3ec66669c1b599ef", + "name": "重庆医科大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_6e0d4a458b619322", + "name": "重庆移通学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://10-252-6-31.webvpn.cqytxy.edu.cn/jwglxt/xtgl/login_slogin.html", + "eligibleForPromotion": false + }, + { + "id": "wakeup_0816c6ae68b22fda", + "name": "重庆邮电大学移通学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_9175e607e6fd98ec", + "name": "重庆幼儿师范高等专科学校", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_1b389c183e239289", + "name": "重庆智能工程职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_0999f38fc0f6b27a", + "name": "滁州职业技术学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_a0ff51e8feea1b9a", + "name": "川北医学院 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_d2505ca78263e111", + "name": "长春大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_cdf8116042f10991", + "name": "长春工业大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_0eca4d6143cf0bd4", + "name": "长春汽车工业高等专科学校", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ce364bd9b09b4243", + "name": "长江大学工程技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_43423e7095dd486b", + "name": "长沙理工大学计通学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_11edad6bcade40c4", + "name": "长沙师范学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_f0b4f480ab70c0f9", + "name": "长沙幼儿师范高等专科学校", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_7dbd821f821c3025", + "name": "大理大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_f4bfd03605c2c3e8", + "name": "大连大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://webvpn.dlu.edu.cn/login", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ce0dbc3b13a7c73d", + "name": "大连海事大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_d20f7e6841ff3687", + "name": "大连海洋大学应用技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_d1de9fb087c6b317", + "name": "大连医科大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_15a4e34ae258d2d7", + "name": "定西师范高等专科学校", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_6da4c41fe375ba2a", + "name": "东北大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_15569f5b90d4b498", + "name": "东北林业大学", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://jwcnew.webvpn.nefu.edu.cn/dblydx_jsxsd/", + "eligibleForPromotion": false + }, + { + "id": "wakeup_dfe483e6a368f16b", + "name": "东莞理工学院 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_2a3adbfdc2f494f1", + "name": "东莞理工学院城市学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_6fda394f2417bb53", + "name": "东华理工大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_086b45986d6e35c3", + "name": "福建工程学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://i.webvpn.fjut.edu.cn/index.portal", + "eligibleForPromotion": false + }, + { + "id": "wakeup_333ea70c7d5b8910", + "name": "福建理工大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://webvpn.fjut.edu.cn/users/sign_in", + "eligibleForPromotion": false + }, + { + "id": "wakeup_6f9f61bfba315b09", + "name": "福建师范大学协和学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_459c075873748a05", + "name": "福建医科大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://eone-443.webvpn.fjmu.edu.cn/oauth2/authorize", + "eligibleForPromotion": false + }, + { + "id": "wakeup_5adf23eeb6a3f78a", + "name": "福建医科大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_fd8abc6fc8bfd776", + "name": "福建中医药大学", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_1e4edf33ec3ab85f", + "name": "福州外语外贸学院 - 研究生", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_3a8c527452f61d14", + "name": "福州英华职业学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ad65f1caff1543da", + "name": "阜阳幼儿师范高等专科学校", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_e0710d36f4b26c01", + "name": "甘肃理工大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_e3975477e364b98e", + "name": "甘肃农业大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_7dba80e3bc6f9f19", + "name": "甘肃卫生职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_982bb87ef8cc2de9", + "name": "甘肃卫生职业学院 - 研究生", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_677078e4d2e42964", + "name": "甘肃医学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_5bb307f4bdef7f00", + "name": "甘肃政法大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_3818db9a45072a8d", + "name": "甘肃中医药大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_de1157c782aa8a8c", + "name": "赣南卫生健康职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_4b9aff64091793c3", + "name": "广东财经大学华商学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_434cbe0c0de33a27", + "name": "广东潮州卫生健康职业学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_4b2a8057e32f3a80", + "name": "广东创新科技职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_1d2691a620daf950", + "name": "广东大学松田学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_a5d7d31a503466c7", + "name": "广东工业大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_f5425656a95b2be0", + "name": "广东工业大学华立学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_aaebd1c47c6f322c", + "name": "广东海洋大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_01d02c6bbb893d8a", + "name": "广东环境工程职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_2525256a5f429941", + "name": "广东技术师范大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://webvpn.gpnu.edu.cn/", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ec6080c21ae4c6e5", + "name": "广东技术师范大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_0464d2865bb48372", + "name": "广东岭南现代技师学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_b784e24679c1a334", + "name": "广东茂名健康职业学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_e25e237d1e233761", + "name": "广东南华工商职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_c8ecc41512292c6d", + "name": "广东青年职业学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_4ef3c2d79fa8c23f", + "name": "广东轻工职业技术学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ab8dd5b0efa86de0", + "name": "广东司法警官职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_75734e4134911b6f", + "name": "广东外语艺术职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_bf16e6a06a29c85c", + "name": "广东舞蹈戏剧职业学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_87a9e675d95b4a92", + "name": "广东行政职业学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://webvpn.gdxzzy.edu.cn/login", + "eligibleForPromotion": false + }, + { + "id": "wakeup_01cb44bd493c5ebc", + "name": "广东职业技术学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_b58d1622443811fe", + "name": "广西大学行健文理学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_b27184649b062280", + "name": "广西电力职业技术学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://ids.vpn.gxdlxy.com/authserver/login", + "eligibleForPromotion": false + }, + { + "id": "wakeup_6edf69b2e9c4461f", + "name": "广西经贸职业技术学院 - 研究生", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_dda7d40826423cb2", + "name": "广西科技大学鹿山学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_6777d0b8033522e4", + "name": "广西民族大学", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://cas-443.webvpn.gxmzu.edu.cn/lyuapServer/login", + "eligibleForPromotion": false + }, + { + "id": "wakeup_d5b6b1f71cb93c2c", + "name": "广西民族大学相思湖学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_95acfe61fd5f50c8", + "name": "广西农业职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_79a86f3466ce8aca", + "name": "广西师范大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ea0c2e2f2c2e089f", + "name": "广西师范学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_10d4d2a7981dd6fd", + "name": "广西师范职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_80d16384b40b6b3b", + "name": "广西体育高等专科学校", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_914287348444c36f", + "name": "广西外国语学校", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_43f6b5e9d67b6026", + "name": "广西梧州学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_634a81fb58efbd93", + "name": "广西职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_db59700c9400975e", + "name": "广西制造工程技术职业学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_7c7ff6afeaf6337c", + "name": "广州财经学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_c9f5502a3817d77e", + "name": "广州大学松田学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_6da996af3b2f6424", + "name": "广州南洋理工职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ce8f575fd3af4555", + "name": "广州中医药大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_1ac7053113d8e454", + "name": "贵阳护理职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_19e2be038d7fcc4d", + "name": "贵阳信息技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_c903e52aeb58e1b4", + "name": "贵阳学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_b3fd399c7306b250", + "name": "贵阳中医学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_f7318c237f007b00", + "name": "贵州大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_2c16bae874bdba8f", + "name": "贵州大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_17830481101a9c97", + "name": "贵州大学科技学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_edb86b05d3a2e30c", + "name": "贵州大学明德学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_05d7a78267e6dd37", + "name": "贵州工程应用技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_560a48eef31e3ac4", + "name": "贵州交通职业技术学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_b521813f6dad3ffb", + "name": "贵州师范大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_098474d28370b614", + "name": "贵州食品工程职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_4cee11b092fe030c", + "name": "桂林电子科技大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_07d488d2ec558ec7", + "name": "桂林理工博文管理学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_15f065f56472f833", + "name": "桂林理工大学博文管理学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_e17049de862fa9b3", + "name": "哈尔滨电力职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_d0995316f55d4406", + "name": "哈尔滨工程大学", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://jwgl.wvpn.hrbeu.edu.cn/jwapp/sys/wdkb/*default/index.do", + "eligibleForPromotion": false + }, + { + "id": "wakeup_6657d2488f469b7f", + "name": "哈尔滨工业大学华德学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_780755a180ba447d", + "name": "哈尔滨华徳学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_f8f1851d4af66add", + "name": "哈尔滨华德学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_2af42f3281ff9879", + "name": "海南大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_b6c5769d3e4ab77f", + "name": "海南工商职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "invalid_url", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_4f49847feb0da41b", + "name": "海南热带海洋学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_06b71d74042c820a", + "name": "海南师范大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_a5f3cf1467c0451b", + "name": "海南医学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_698bd4e40c2e1e29", + "name": "邯郸学院 - 研究生", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_48b26e2d94f94ffa", + "name": "汉江师范学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://webvpn.hjnu.edu.cn/", + "eligibleForPromotion": false + }, + { + "id": "wakeup_e638cd39459ab34f", + "name": "汉口学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_69bfda1119ed4128", + "name": "杭州电子科技大学信息工程学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_5eb71df31f553fa7", + "name": "杭州医学院 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_7827c2f798fa5083", + "name": "杭州职业技术学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_bc1172ee07946248", + "name": "合肥城市学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ca1723628f1b8b59", + "name": "合肥工业大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_90690a711067840e", + "name": "合肥学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_71b339243452e3cb", + "name": "河北北方学院动物科技学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_da83ca6bed4cba67", + "name": "河北地质大学华信学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_4c256dba7336e470", + "name": "河北东方学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_c71bce252b007e48", + "name": "河北工业职业技术大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "invalid_url", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_13577a998ad898d0", + "name": "河北工业职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_6655f4a30162c323", + "name": "河北环境工程学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://webvpn.hebuee.edu.cn/", + "eligibleForPromotion": false + }, + { + "id": "wakeup_fb288d50866524a7", + "name": "河北机电职业技术学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "invalid_url", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_11464b9c5e4a0188", + "name": "河北交通职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "invalid_url", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_9da55ba526080274", + "name": "河北经贸大学经济管理学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ddabcaddd62e1c5f", + "name": "河北科技大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_4d65f5c391e7b4f5", + "name": "河北科技大学理工学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_1e05ac49a67d821d", + "name": "河北科技师范学院 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_fe1203d6153f53fb", + "name": "河北美术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_4c564d3eb76d14a8", + "name": "河北农业大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_7b7686fe0263d20b", + "name": "河北农业大学海洋学院", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_6e9ff20b27d19683", + "name": "河北外国语学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_68bca5073ec47ce6", + "name": "河北医科大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_b7e2877940563970", + "name": "河南财政金融学院象湖校区", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_c36306581036b51f", + "name": "河南工业大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_cba2e4d55dd9a4e3", + "name": "河南机电职业学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_80a32144250ad212", + "name": "河南科技大学应用工程学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_52bba7d40c44b295", + "name": "河南牧业经济学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_8ec98747b6156b0c", + "name": "河南师范大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_29b3a29a5633da3f", + "name": "河南中医药大学", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_880acdc2be3df44f", + "name": "河西学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_80453ea02c2bb26e", + "name": "黑河学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://172-16-121-26.webvpn.hhxy.edu.cn/jwglxt/kbcx/xskbcx_cxXskbcxIndex.html", + "eligibleForPromotion": false + }, + { + "id": "wakeup_4c358b88d3a56b8f", + "name": "黑龙江工程学院昆仑旅游学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_2b3e14f937276329", + "name": "黑龙江工业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "http://webvpn.hljut.edu.cn/users/sign_in", + "eligibleForPromotion": false + }, + { + "id": "wakeup_6c047294f47c74ba", + "name": "横店影视学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_c1987e7c5a68bad2", + "name": "衡阳师范学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_f2b33538bca70bc4", + "name": "呼和浩特职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_226deca56255f4f1", + "name": "呼伦贝尔学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "invalid_url", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_2c8ccfd77288fa35", + "name": "呼伦贝尔学院 - 研究生", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_85577ad06cc82633", + "name": "湖北大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_a5b5640d81894f3c", + "name": "湖北经济学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_5fe7b8a1daa3e6a3", + "name": "湖北经济学院 - 研究生", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_3f077855c9a0fe32", + "name": "湖北警官学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "invalid_url", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_1b5fc5c351e2a97d", + "name": "湖北师范大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_7cd0ee5e5b6d3c83", + "name": "湖北师范大学文理学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_80123e469eb04ca3", + "name": "湖北文理学院 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_f599aa58f3d65eb5", + "name": "湖北孝感美珈职业学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_c8ad1e65250da4a6", + "name": "湖北中医药大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_d39e6d3497e7b160", + "name": "湖南工商大学北津学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_f8eca1ec67f03af4", + "name": "湖南工业职业学院", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_bf74aa45cb75abf1", + "name": "湖南吉利汽车职业技术学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_c8514b0f761c9aa6", + "name": "湖南吉利汽车职业技术学院 - 研究生", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_5709c91994ff17cf", + "name": "湖南科技大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_655be71724b9309e", + "name": "湖南劳动人事职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "invalid_url", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_5e1f8b991b4f0dc0", + "name": "湖南理工学院 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_b0556ae613a520ee", + "name": "湖南南华大学", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_35f803b0967a22d8", + "name": "湖南南华大学船山学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_19a5d41eb46afcb0", + "name": "湖南农业大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_9f583256e23a99e1", + "name": "湖南商学院北津学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_e36987a333b9a365", + "name": "湖南师范大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_5dd1fb7708db3b45", + "name": "湖南铁路科技职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ceb7d715e8ce7d0f", + "name": "湖南外国语职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_60bac764e1096713", + "name": "湖南文理学院芙蓉学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_8413249f0ea1071a", + "name": "湖州师范学院求真学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_145925fb48f6a86a", + "name": "华北电力大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://ids-443.webvpn.ncepu.edu.cn/", + "eligibleForPromotion": false + }, + { + "id": "wakeup_fefaabf005031e46", + "name": "华北电力大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_d804bda43ab01d14", + "name": "华北电力大学北京校区", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_3f7292ab3fd2af2c", + "name": "华北电力大学科技学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_02ec2ba581730b9b", + "name": "华东交通大学理工学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_7c8be92e9029337a", + "name": "华东政法大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_8f8eacf7d74329b2", + "name": "华立技师学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_83a3bbeec054e7c7", + "name": "华南理工大学广州学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_bb250376e3120ed7", + "name": "华中科技大学文华学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_7925dedd27699a3a", + "name": "淮安生物工程高等职业学校", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_6dd7a27a87dd0868", + "name": "淮安信息技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_5890bd6319d9ed23", + "name": "淮安信息职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ad0752f34e1c8e38", + "name": "淮海工学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_316c9fc189379c9d", + "name": "淮南师范学院泉山校区", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_5de311cfd5fb7147", + "name": "黄河科技学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://vpn.hhstu.edu.cn/login/index", + "eligibleForPromotion": false + }, + { + "id": "wakeup_8b392a07660848a4", + "name": "黄山学校", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_40dfae48f13a564a", + "name": "冮苏工程职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_5d1d387ca3c834be", + "name": "吉安幼儿师范高等专科学校", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "no_family_signature", + "safeUrl": "https://jw.japec.edu.cn//sso/lyiotlogin", + "eligibleForPromotion": false + }, + { + "id": "wakeup_019e50c4f729b97d", + "name": "吉利学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_025e3160a9dc2540", + "name": "吉利医药学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_47219b08504d0a13", + "name": "吉林财经大学", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_fea67b55d64e6689", + "name": "吉林大学", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://vpn.jlu.edu.cn/login", + "eligibleForPromotion": false + }, + { + "id": "wakeup_4a966bde787a1e55", + "name": "吉林大学(新)教务管理系统 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://vpn.jlu.edu.cn/login", + "eligibleForPromotion": false + }, + { + "id": "wakeup_0dfc83e037c608c8", + "name": "吉林大学珠海学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_850e043e04b51808", + "name": "吉林电子信息职业技术学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "invalid_url", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_754bf32cfac60e84", + "name": "吉林工程技术师范", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_a55cf643d18e4571", + "name": "吉林华桥外国语学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_4f69bc1a7a9c0676", + "name": "吉林体育学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://webvpn.jlsu.edu.cn:4431/portal/", + "eligibleForPromotion": false + }, + { + "id": "wakeup_d20a7d7ea8c951f2", + "name": "吉林外国语大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_c3029ce3f9776a75", + "name": "吉林医药学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "vpn_portal_without_resource", + "safeUrl": "http://vpn.jlmu.cn/users/sign_in", + "eligibleForPromotion": false + }, + { + "id": "wakeup_d1fc45ee0dc1a641", + "name": "吉首大学", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://webvpn.jsu.edu.cn/", + "eligibleForPromotion": false + }, + { + "id": "wakeup_84e1e5f81aef3f6e", + "name": "集宁师范学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_3789feafc9490224", + "name": "济宁学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_12c696022b605429", + "name": "济宁职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_9aaba22b229044ee", + "name": "暨南大学", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://webvpn.jnu.edu.cn/", + "eligibleForPromotion": false + }, + { + "id": "wakeup_f4596b4e748d9e26", + "name": "嘉兴学院南湖学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_d9c9733d3cc53983", + "name": "嘉应学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "invalid_url", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_dbb240d825c0c18a", + "name": "嘉应学院医学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_2c5868a108fb7089", + "name": "江汉大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_591d3d772c80d69a", + "name": "江汉大学文理学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ea2c67948bc63e70", + "name": "江南大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ef7badc0f622b93b", + "name": "江苏财经职业技术学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_1dcbe5a72f199d48", + "name": "江苏城乡建设学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_856d4a5a9463a1cb", + "name": "江苏电子信息职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_5d577a750880a3ad", + "name": "江苏电子信息职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://jwpd.webvpn.jsei.edu.cn/sso/jznewsixlogin", + "eligibleForPromotion": false + }, + { + "id": "wakeup_fe8aad1345162601", + "name": "江苏科技大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_6da8ad79d3039010", + "name": "江西师范大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_e9a18faa447b7d77", + "name": "江西卫生职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_dd60c52567729c24", + "name": "江西中医药大学科技学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_a47c26d719a1006b", + "name": "焦作师范高等专科学校", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://webvpn.jzsz.edu.cn/login/index", + "eligibleForPromotion": false + }, + { + "id": "wakeup_5f2765e6b9ebaa4f", + "name": "景德镇陶瓷职业技术学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_07693e907daa0a2d", + "name": "九江职业技术学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_b5c969d31360d001", + "name": "九州职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_0432b0ba91c2302e", + "name": "开封职业学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_52dbedba161433fe", + "name": "凯里学院 - 研究生", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_f61cb88d3d8d1703", + "name": "空军军医大学", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_61feb2003f12468d", + "name": "昆明国土资源学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_b35e235b3c50e9be", + "name": "昆明理工大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_a5b666e714789dd0", + "name": "昆明医科大学", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_5db00e7dc3bc3677", + "name": "昆明医科大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_e274521c4c47b5f4", + "name": "莱芜职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_2e9831785c87947a", + "name": "兰考三农职业学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_be18229ac0195d65", + "name": "兰州财经大学陇桥学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_576ca7610435e707", + "name": "兰州财经大学长青学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_3d497d216597e620", + "name": "兰州交通大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_4f7503d852b1d6f0", + "name": "兰州理工大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_fa2de65bc01e6e79", + "name": "兰州理工大学技术工程学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_14fa19d4c170715f", + "name": "兰州资源环境职业技术学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_7e8660fe698e1932", + "name": "廊坊师范学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "invalid_url", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_3eb9c6480a3c9379", + "name": "廊坊卫生职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://vpn.lfwx.net:44321/portal/", + "eligibleForPromotion": false + }, + { + "id": "wakeup_03470e66561f22bb", + "name": "连云港师范高等专科学校", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_5aa68b416272e803", + "name": "辽宁传媒学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_2d395a9622f70fc1", + "name": "辽宁工程技术大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_c0d45433b50ef0da", + "name": "辽宁警察学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_5edb2142a149adf6", + "name": "辽宁农业职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_0abff3df8cdd0a08", + "name": "辽宁特殊教育师范高等专科学校", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_428314ee809eab80", + "name": "聊城职业技术学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_dd7940759eca5c6c", + "name": "林业职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_eba99edb6a439614", + "name": "岭南师范学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_47bf027e508c0ddf", + "name": "陆军军医大学", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_34770b04e6eb5bbb", + "name": "满洲里俄语职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "invalid_url", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_7c15852ba888e75c", + "name": "闽南师范大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://sso-443.webvpn.mnnu.edu.cn/login", + "eligibleForPromotion": false + }, + { + "id": "wakeup_859c11fc4f0c3c2f", + "name": "牡丹江师范学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "vpn_portal_without_resource", + "safeUrl": "http://webvpn.mdjnu.edu.cn/", + "eligibleForPromotion": false + }, + { + "id": "wakeup_984b1770ba3f5c33", + "name": "南昌大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_b7b75b697f25c0e7", + "name": "南昌航空大学科技学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ea72f4ad23c112e4", + "name": "南京工业大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ddc229455635e0ca", + "name": "南京工业大学-研究生 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_e13957d4e2ea4393", + "name": "南京航空航天大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_96550d83c768a6d5", + "name": "南京审计大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_f61907f24ec0bcf3", + "name": "南京师范大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ae8f5901a2535c3f", + "name": "南京师范大学中北学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_dd3842e7feba8b81", + "name": "南京特殊教育师范学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_60a7f85162ee8c08", + "name": "南京信息工程大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_45be20bcdbf46e5a", + "name": "南京信息工程大学滨江学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_5028744a1ee4eabb", + "name": "南京医科大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_26597f5b4a7f10a7", + "name": "南京中医药大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_65240f3db58b3d8e", + "name": "南通大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_3672be2203a1e54d", + "name": "南通航运职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_72fb4d4754ebdfa5", + "name": "南通科技职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_99ac3cfc5d89c76f", + "name": "内蒙古包头师范学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_e576908e9836ac84", + "name": "内蒙古大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_cb2530bd51793df4", + "name": "内蒙古电子信息职业技术学院 - 研究生", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_7c58065db0cef3f4", + "name": "内蒙古工业大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_04541cf9a1d196b6", + "name": "内蒙古警察职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_6b8323d3aa746624", + "name": "内蒙古科技大学包头师范学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_3b15384a97052c6f", + "name": "内蒙古科技大学包头医学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_9360bc74731246ae", + "name": "内蒙古民族大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_69bd6b2f71b30a86", + "name": "宁波大红鹰学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_fe103e04fbbabea9", + "name": "宁波卫生职业技术学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://vpn.nchs.edu.cn/portal/", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ce1ba26aabeeda92", + "name": "宁波职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_576d0fe23213faf2", + "name": "宁夏师范大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_59435a923bf24158", + "name": "齐齐哈尔高等师范专科学校", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_8c50fdc539b45e69", + "name": "黔南民族幼儿师范高等专科学校", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_8af683606643f43d", + "name": "秦皇岛职业技术学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_69284ae3087f462b", + "name": "青岛滨海学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_f1225f835888ce37", + "name": "青岛电影学院", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_4cfd6a1e5f800e2d", + "name": "青岛科技大学高密校区", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_87414ac5baa07e75", + "name": "青岛理工大学临沂校区", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_8ddf206bbace9068", + "name": "青海大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_3580bdcdde36333c", + "name": "曲阜师范大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_e6891b4e9fc214d8", + "name": "衢州职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://vpn.qzct.net/", + "eligibleForPromotion": false + }, + { + "id": "wakeup_a79fc00802e2f39f", + "name": "泉州纺织服装学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_79456bf510d6beef", + "name": "日照航海工程职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_8b09f02ebb8013d8", + "name": "三明医学科技职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ae794a5ec4568576", + "name": "三峡医药高等专科学校", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_44952239427ef4db", + "name": "三亚学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_899059c774470f07", + "name": "山东第二医科大学", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://webvpn.sdsmu.edu.cn/login", + "eligibleForPromotion": false + }, + { + "id": "wakeup_3a16bea693803c50", + "name": "山东第二医科大学(原潍坊医学院)", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://webvpn.sdsmu.edu.cn/login", + "eligibleForPromotion": false + }, + { + "id": "wakeup_e7e45186c0390709", + "name": "山东第一医科大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_5299cb0067b0c0fa", + "name": "山东电子职业技术学院 - 研究生", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_b16790a911206b77", + "name": "山东服装职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_2e2170bc0058a0b5", + "name": "山东华宇工学院 - 研究生", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_368b86f74a4bfbec", + "name": "山东交通学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_11327d2bb912bc56", + "name": "山东科技大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_b4b9bc3bbd5da457", + "name": "山东科技大学泰安科技学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_3cdc68f00c8a9928", + "name": "山东理工大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_f9ac20a09532749b", + "name": "山东理工大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_3d69f14533ef091e", + "name": "山东司法警官职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_0e5a1a94200672fd", + "name": "山东外贸职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_863f4a2e367443e8", + "name": "山东文化产业职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_adf7586661083906", + "name": "山东现代学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_57f46a43be98496a", + "name": "山东政法大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ee62c2e3d7b6faca", + "name": "山东中医院大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_b97403d9ee9bdc72", + "name": "山西大同大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ea35f163c4aeac42", + "name": "山西大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_d5f6c21ba65b7008", + "name": "山西工程科技职业大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_26c40aad7d60f0ff", + "name": "山西晋中理工学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_7f8b8dfe330ca357", + "name": "山西警察学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_3565c401d6df8eb3", + "name": "山西科技学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_77b22717c81477d5", + "name": "山西医科大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_536c3c0ad3250fd2", + "name": "山西应用科技学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "invalid_url", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_76fdc8753a7ece70", + "name": "陕西机电职业技术学校", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_cb46813c51812e51", + "name": "陕西交通职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_582eef077399dcd1", + "name": "陕西能源职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_21176477cbbbb3f9", + "name": "陕西省建筑材料工业学校", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_f9f7b3823e74fd96", + "name": "陕西铁路工程职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_aa587012decfc111", + "name": "汕头大学", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "vpn_portal_without_resource", + "safeUrl": "http://jw-stu-edu-cn.webvpn.stu.edu.cn:8118/", + "eligibleForPromotion": false + }, + { + "id": "wakeup_acfa4ee6a2634b5e", + "name": "上海电力大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_d22ebefe03f4e0dc", + "name": "上海交通大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_9d389ee2b5d93fd7", + "name": "上海交通大学医学院 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_2247e9280b5b725b", + "name": "上海理工大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_6f0295a106fd7e75", + "name": "上海商学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_7dfcd14c57da7028", + "name": "上海师范大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_a9e8ffcbfe2f0d5c", + "name": "上海体育大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_abc877cefb73c00e", + "name": "上海体育学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_c32ccfe2eb4396b6", + "name": "上海体育学院 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_8dc66acf81f6d6bc", + "name": "上海外国语大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_0b3c2dbc143d6ce1", + "name": "上海政法学院 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_221a2be64eb625e1", + "name": "绍兴文理学院 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_6973b042a4cf0b58", + "name": "绍兴文理学院元陪学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_cafa41a81efd2e05", + "name": "深圳大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_81258b2b69838935", + "name": "深圳职业技术大学", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_30a13bae9c9fb286", + "name": "深圳职业技术学院", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_27fa361f7855654b", + "name": "沈阳航空航天大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://webvpn.sau.edu.cn/Unauthorized", + "eligibleForPromotion": false + }, + { + "id": "wakeup_8d2677c85e23a470", + "name": "沈阳理工大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_40c91b3cb73f0a38", + "name": "沈阳师范大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://210-30-208-200.webvpn.synu.edu.cn/(ku5dsqznpdlpim45ytupko55)/default2.aspx", + "eligibleForPromotion": false + }, + { + "id": "wakeup_f550d8a34ec1eb10", + "name": "沈阳职业技术学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_6fb884ce219a53a7", + "name": "石家庄铁路职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "invalid_url", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_9d07db20dfe13fda", + "name": "首都师范大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_b98bf9227b3e5a3a", + "name": "四川财经职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "http://cas-scpcfe-cn.vpn.scpcfe.cn:8118/cas/login", + "eligibleForPromotion": false + }, + { + "id": "wakeup_85a7e6de99a713f8", + "name": "四川机电职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_40eb9b32e40208e2", + "name": "四川交通职业技术", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_f647c38ce53680ee", + "name": "四川理工学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_450e62c345a5bd38", + "name": "四川美术学院 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_2051cabdc132ba94", + "name": "四川天一学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_7e05457a07412433", + "name": "四川托普信息职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_d0594e18525a9fec", + "name": "四川外国语大学成都学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_f85cf35866b45a82", + "name": "四川文轩职业学校", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ce108ce0f54dc81a", + "name": "四川邮电职业技术学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "vpn_portal_without_resource", + "safeUrl": "http://ces-sptc-cn.vpn.sptc.cn:8118/cas/login", + "eligibleForPromotion": false + }, + { + "id": "wakeup_697d4aada08c518b", + "name": "四川幼儿师范高等专科学校", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_9808a76ce568451e", + "name": "苏州高博软件技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_fd8bda66f100c001", + "name": "苏州高博软件技术职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_35d0915479d36361", + "name": "苏州工业园区服务外包学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_fd4a20035236b830", + "name": "苏州健雄职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://jxvpn.csit.edu.cn:10443/enlink/sso/login", + "eligibleForPromotion": false + }, + { + "id": "wakeup_4770230e093388fc", + "name": "苏州科技大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_bb0a24974f1667e2", + "name": "苏州市职业大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_48721b7aa83b9a28", + "name": "宿州学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_48ac71d8b0f78a75", + "name": "太湖学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_cae748236aa8b7e8", + "name": "太原科技大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ae4650a5a419dfe7", + "name": "太原科技大学华科学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_588f7a4ee1115287", + "name": "太原理工大学现代科技学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_a40121dd1a989bf6", + "name": "唐山工业职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_29c4208a1c4aa994", + "name": "唐山海运职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "invalid_url", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_d0f78fef6e74af4d", + "name": "唐山职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://cas.vpn.tsvtc.edu.cn:20000/cas/login", + "eligibleForPromotion": false + }, + { + "id": "wakeup_f82b2dc4f3ded369", + "name": "天津滨海职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "invalid_url", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_19b7a8e13beede6a", + "name": "天津城建大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_1c42058e5e976b9a", + "name": "天津城市职业学院 - 研究生", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_2ae9dfbb2eade404", + "name": "天津大学仁爱学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_59e7fc0c7b000ffc", + "name": "天津工业大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_44a7b1502c030230", + "name": "天津科技大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_94cee5c552d44cf9", + "name": "天津师范大学", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_722e467e0b93a378", + "name": "天津市职业大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://vpn.tjtc.edu.cn/portal/", + "eligibleForPromotion": false + }, + { + "id": "wakeup_2cbbb0435f601da8", + "name": "天津体育学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://vpn.tjus.edu.cn/users/sign_in", + "eligibleForPromotion": false + }, + { + "id": "wakeup_483e0b7349aec53f", + "name": "天津体育学院 - 研究生", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_aefff3e0c41a3b93", + "name": "天津外国语大学滨海外事学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_62ce29aad3b9e16d", + "name": "天津医科大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_d9d2c149b428bf85", + "name": "天津职业大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_06801d2421cbaa55", + "name": "铁道警察学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_cc0903618de9b2a5", + "name": "潍坊工程职业学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_4e8101f113c03eb4", + "name": "潍坊科技学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ec8e9ddbe2dda850", + "name": "潍坊医学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://webvpn.sdsmu.edu.cn/login", + "eligibleForPromotion": false + }, + { + "id": "wakeup_97825f7186574d94", + "name": "渭南师范学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_951dfc51f094fa67", + "name": "温州大学瓯江学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_cabbd56df13e88c2", + "name": "梧州职业学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_35b196dcd62e7595", + "name": "五邑大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_f58fcf4f0a46b256", + "name": "武昌工学院 - 研究生", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ca2e22f3d8ed6387", + "name": "武汉电力职业学院技术", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ecef842d67368da1", + "name": "武汉纺织大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_beb2f072af1a7af6", + "name": "武汉工程大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_9109b66a872368e5", + "name": "武汉工程职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_45d499df66a404e5", + "name": "武汉商贸职业学校", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_c5a97994042185b2", + "name": "厦门海洋职业技术学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_73bd0d204a35ebfc", + "name": "厦门南洋职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_91cbf234c42113b0", + "name": "厦门市华天涉外职业技术学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_171e49da11705f90", + "name": "西安财经大学行知学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_fa1c31a888d8f7ff", + "name": "西安翻译学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_49d4080d938bdfcc", + "name": "西安工程大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_9af9683b9125330d", + "name": "西安交通工程学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ec532963616da0b0", + "name": "西安理工大学高科学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_917a4047f28bc03c", + "name": "西安石油大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_62fbb9148362ce43", + "name": "西北农林科技大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_45323e8c3a5800c6", + "name": "西北师范大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_672a9eba236c1415", + "name": "西北师范大学知行学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_7a08cd9628428b61", + "name": "西藏大学", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_d3a9d39cf0a3d0b0", + "name": "西藏农牧大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_874781dc8b7ed373", + "name": "西藏农牧学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_6953bac984a0516f", + "name": "西昌民族幼儿师范高等专科学校", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_8196c4478dcfc7de", + "name": "西华大学应用技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_c062787eeaf173a3", + "name": "西南大学荣昌校区", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_d688abf6326d1750", + "name": "西南石油大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_a22b82cd14fb3db6", + "name": "湘潭大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_566d8689cd065d70", + "name": "湘潭理工学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_84923e79a455a04e", + "name": "忻州师范学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_01b60334660dfdd9", + "name": "新疆财经大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_1756eee528b28d2c", + "name": "新疆第二医学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_7f532066ad24d84e", + "name": "新疆理工学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://cas-xjit-edu-cn-443.webvpn.xjistedu.cn/cas/login", + "eligibleForPromotion": false + }, + { + "id": "wakeup_9f60124b52fad3b4", + "name": "新疆农业大家 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_02c8a9d582738fce", + "name": "新疆农业大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_fdb89b11262fb584", + "name": "新疆医科大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_f5c04fa567f04f4f", + "name": "新疆医科大学厚博学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_70c93817c3a5cdb2", + "name": "新疆政法学校", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_216a0c8c3556a8c0", + "name": "信阳师范大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_aeb8d10d5e238a67", + "name": "信阳艺术职业学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_5196317dbdfd0632", + "name": "邢台职业技术学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_6d268f72647cf437", + "name": "徐州工程学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_503d596ac3a851a7", + "name": "徐州医科大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_b737a2c4f911f9a0", + "name": "许昌学院医学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_3ef81e6ed13cdf68", + "name": "许昌职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_a1d303d10dc0fff0", + "name": "烟台大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_061608a722d10b73", + "name": "伊犁师范大学霍尔果斯商学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_774f7d839bbd4da4", + "name": "右江民族医学院 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_2aa13d6ec9d38c2a", + "name": "玉林师范学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_660830660c2147e4", + "name": "玉溪农业职业技术学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_b61bfaa1f6b2609f", + "name": "豫章师范学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://vpn.yuznu.edu.cn/portal/", + "eligibleForPromotion": false + }, + { + "id": "wakeup_81404e9c5e08b743", + "name": "云南大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_3d678d0b7eb055c6", + "name": "云南农业大学热带作物学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_6f09591eb50fe1eb", + "name": "云南锡业职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_23e3419af5d94539", + "name": "运城职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_2db64d531bcb02ba", + "name": "扎兰屯职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_2cbd076c027cf305", + "name": "张家口职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_cbc8d92ac5b8dace", + "name": "浙江财经大学东方学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "http://cas-paas-443.webvpn.zufedfc.edu.cn/cas//login", + "eligibleForPromotion": false + }, + { + "id": "wakeup_19b1b7a1712c0e12", + "name": "浙江大学宁波理工学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_58100e8c21308525", + "name": "浙江东方职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_f366a919330e59a9", + "name": "浙江纺织纺织职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_08019d88fc13fb30", + "name": "浙江纺织服装技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_94d858e46e82355d", + "name": "浙江纺织服装技术职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_22a5dc5977c75cfb", + "name": "浙江工商大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_b9049d9307380516", + "name": "浙江工业大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_8343184199445e6e", + "name": "浙江广厦建设职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_da90ec6b56685e9d", + "name": "浙江广厦职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_4b8b832a1b0449d8", + "name": "浙江海洋大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_4a2df45956109ed3", + "name": "浙江海洋大学东海科学技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_2c9481fca3721e18", + "name": "浙江机电职业技术大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_00da54deb35337e7", + "name": "浙江机电职业技术学校", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_6bcab83f510a5eea", + "name": "浙江经济职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://dean-443.webvpn.zjtie.edu.cn/jwglxt/xtgl/login_slogin.html", + "eligibleForPromotion": false + }, + { + "id": "wakeup_95975917802ff8ab", + "name": "浙江警察学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://0vpn.zjjcxy.cn/portal/", + "eligibleForPromotion": false + }, + { + "id": "wakeup_fbc71a8b58cd0961", + "name": "浙江警官职业学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://webvpn.zjjy.com.cn/portal/", + "eligibleForPromotion": false + }, + { + "id": "wakeup_29b47cded895a445", + "name": "浙江科技学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_086319da76a9d144", + "name": "浙江科贸职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_1e64f78a1ddfff22", + "name": "浙江理工大学科技与艺术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://sso-ky-zstu-edu-cn-s.webvpn2.ky.zstu.edu.cn:8070/login", + "eligibleForPromotion": false + }, + { + "id": "wakeup_8accae7856d12251", + "name": "浙江旅游职业学院 - 研究生", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ebacadaab9797fd3", + "name": "浙江省医学科学院 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_92dfad12ee342a37", + "name": "浙江师范大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_117afb85e2e422b9", + "name": "浙江师范大学杭州幼儿师范学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_5019d0c1da5e6e44", + "name": "浙江树人大学", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_eb79c32aa1f608c5", + "name": "镇江高等专科学校", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_e0125eaedc4a19e5", + "name": "郑州财税金融学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_1c430643e8cc7aba", + "name": "郑州大学旅游管理学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_8af3df442e9ba965", + "name": "郑州工程技术学院", + "sourceType": "kingo_new", + "adapterId": "kingosoft_new", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_b46a0e847d4b9063", + "name": "中北大学信息商务学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_f4bb93dfe47174eb", + "name": "中国传媒大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_dd0575e469f4135f", + "name": "中国传媒大学南广学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_14e561555364eacd", + "name": "中国地质大学(北京) - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_20f801a34df407ef", + "name": "中国地质大学长城学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_cfe9c82ce1e3970a", + "name": "中国海洋大学青岛学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_2fad7f4ca7d15f93", + "name": "中国航空研究院研究生院 - 研究生", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_0857188bd1ee1b02", + "name": "中国矿业大学南湖校区", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_c531c4ad84855485", + "name": "中国矿业大学银川学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_b9de1f94a699545e", + "name": "中国民用航空飞行学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://vpn.cafuc.edu.cn/", + "eligibleForPromotion": false + }, + { + "id": "wakeup_273ef0fe130edc0b", + "name": "中国农业大学(烟台)", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_0a8264df84bf7144", + "name": "中国农业大学(烟台) - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_af6ef35319c3caf2", + "name": "中国农业大学烟台研究院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_41749323eda43dde", + "name": "中国人民解放军陆军军医大学", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_6c704253fbd87076", + "name": "中国石油大学(北京) - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_8dd3e390daa7272d", + "name": "中国石油大学胜利学院", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_59dc1b99995dd16e", + "name": "中国医科大学 - 研究生", + "sourceType": "south_soft", + "adapterId": "south_soft", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_ac3524de6a47ab0b", + "name": "中南财经政法大学 - 研究生", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_4c8dc139772970a4", + "name": "中南林业科技大学", + "sourceType": "qz", + "adapterId": "qiangzhi_standard", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://webvpn.csuft.edu.cn/", + "eligibleForPromotion": false + }, + { + "id": "wakeup_119772e44f5b119d", + "name": "中南民族大学", + "sourceType": "jz", + "adapterId": "wisedu_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://webvpn.scuec.edu.cn/", + "eligibleForPromotion": false + }, + { + "id": "wakeup_2cd80d3cf12d8841", + "name": "中山大学新华学院东莞校区", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_43a78da7879144ea", + "name": "中山火炬职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "ip_literal", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_290caf93b58d4317", + "name": "中山市火炬职业技术学院", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "missing_reference", + "safeUrl": "", + "eligibleForPromotion": false + }, + { + "id": "wakeup_5cf9a74bd2547d54", + "name": "中央财经大学 - 研究生", + "sourceType": "zf", + "adapterId": "zhengfang_auto", + "reason": "vpn_portal_without_resource", + "safeUrl": "https://yjsjy.webvpn.cufe.edu.cn/yjsxt/xtgl/login_slogin.html", + "eligibleForPromotion": false + } + ] +} diff --git a/analysis/school-adapter-v2-20260909/endpoint-audit.md b/analysis/school-adapter-v2-20260909/endpoint-audit.md new file mode 100644 index 0000000..9d51ad5 --- /dev/null +++ b/analysis/school-adapter-v2-20260909/endpoint-audit.md @@ -0,0 +1,15 @@ +# 五大家族公开入口核验 + +- 扫描条目:578 +- 本次提升为 experimental:0 +- 结论:目录中这些 adapter_required 条目没有同时具备学校官方公开证据与可安全加载的明确入口,保持不可导入。 + +## 原因统计 + +- invalid_url: 15 +- ip_literal: 141 +- missing_reference: 357 +- no_family_signature: 1 +- vpn_portal_without_resource: 64 + +完整逐条结果见 endpoint-audit.json。脚本不会下载或保存课表响应。 diff --git a/analysis/school-adapter-v2-20260909/network-security-validation.json b/analysis/school-adapter-v2-20260909/network-security-validation.json new file mode 100644 index 0000000..deb147a --- /dev/null +++ b/analysis/school-adapter-v2-20260909/network-security-validation.json @@ -0,0 +1,6 @@ +{ + "cleartextEntries": 935, + "baseConfigAllowsCleartext": true, + "domainRuleCount": 0, + "valid": true +} diff --git a/analysis/school-adapter-v2-20260909/upgrade-directory-v3.mjs b/analysis/school-adapter-v2-20260909/upgrade-directory-v3.mjs new file mode 100644 index 0000000..235efe1 --- /dev/null +++ b/analysis/school-adapter-v2-20260909/upgrade-directory-v3.mjs @@ -0,0 +1,55 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, "../.."); +const asset = path.join(root, "app/src/main/assets/academic_school_directory.json"); + +const sourceAdapters = new Map([ + ["zf", "zhengfang_auto"], ["zf_1", "zhengfang_legacy"], + ["zf_new", "zhengfang_jwglxt"], + ["qz", "qiangzhi_standard"], ["qz_2017", "qiangzhi_2017"], + ["qz_2024", "qiangzhi_2024"], ["qz_br", "qiangzhi_br"], + ["qz_with_node", "qiangzhi_node"], ["qz_crazy", "qiangzhi_crazy"], + ["jz", "wisedu_auto"], ["jz_1", "wisedu_auto"], ["jz_x", "wisedu_auto"], + ["kingo_new", "kingosoft_new"], ["kg_zx", "kingosoft_selected"], + ["qingguo", "kingosoft_selected"], ["south_soft", "south_soft"] +]); + +const profileAdapters = new Map([ + ["zhengfang", "zhengfang_auto"], ["qiangzhi", "qiangzhi_auto"], + ["qiangzhi_legacy", "qiangzhi_standard"], ["wisedu", "wisedu_auto"], + ["kingosoft_new", "kingosoft_new"], ["kingosoft_selected", "kingosoft_selected"], + ["south_soft", "south_soft"], ["eams", "eams_table0"], + ["shuwei_new", "shuwei_local"], ["shuwei_mobile", "shuwei_local"], + ["shuwei", "shuwei_local"], ["shuwei_easy", "shuwei_local"] +]); + +function stripSessionPathParameters(value) { + return typeof value === "string" + ? value.replace(/;jsessionid=[^/?#;]*/gi, "") + : value; +} + +function sanitizeReferenceUrl(value) { + const clean = stripSessionPathParameters(value); + return typeof clean === "string" ? clean.split(/[?#]/, 1)[0] : clean; +} + +const catalog = JSON.parse(fs.readFileSync(asset, "utf8")); +if (![1, 2, 3].includes(catalog.schemaVersion) || !Array.isArray(catalog.entries)) { + throw new Error("Unsupported directory input"); +} +catalog.schemaVersion = 3; +for (const entry of catalog.entries) { + entry.url = stripSessionPathParameters(entry.url); + entry.referenceUrl = sanitizeReferenceUrl(entry.referenceUrl); + for (const key of ["loginUrls", "authenticationUrls", "timetableUrls"]) { + if (Array.isArray(entry[key])) entry[key] = entry[key].map(stripSessionPathParameters); + } + entry.adapterId = sourceAdapters.get(entry.sourceType) || + profileAdapters.get(entry.profile) || entry.profile || ""; +} +fs.writeFileSync(asset, `${JSON.stringify(catalog)}\n`, "utf8"); +console.log(`schemaVersion=3 entries=${catalog.entries.length}`); diff --git a/analysis/school-adapter-v2-20260909/validate-directory-security.mjs b/analysis/school-adapter-v2-20260909/validate-directory-security.mjs new file mode 100644 index 0000000..3a6621f --- /dev/null +++ b/analysis/school-adapter-v2-20260909/validate-directory-security.mjs @@ -0,0 +1,24 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, "../.."); +const catalog = JSON.parse(fs.readFileSync( + path.join(root, "app/src/main/assets/academic_school_directory.json"), "utf8" +)); +const xml = fs.readFileSync(path.join(root, "app/src/main/res/xml/network_security_config.xml"), "utf8"); +const cleartextEntries = catalog.entries.filter((entry) => + entry.support !== "adapter_required" && entry.cleartext +).length; +const baseConfigAllowsCleartext = //.test(xml); +const domainRuleCount = [...xml.matchAll(/)/g)].length; +const result = { + cleartextEntries, + baseConfigAllowsCleartext, + domainRuleCount, + valid: baseConfigAllowsCleartext && domainRuleCount === 0 +}; +fs.writeFileSync(path.join(here, "network-security-validation.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8"); +console.log(JSON.stringify(result)); +if (!result.valid) process.exitCode = 1; diff --git a/analysis/school-url-entry-20260909/README.md b/analysis/school-url-entry-20260909/README.md new file mode 100644 index 0000000..0968ee1 --- /dev/null +++ b/analysis/school-url-entry-20260909/README.md @@ -0,0 +1,20 @@ +# School URL entry audit + +WakeUp 6.1.20's decrypted `school_list.json` contains 3,570 rows: 3,091 have a +non-empty URL and 479 do not. All 22 generic-system rows have an empty URL by +design. They select a parser family; the user still needs to supply the +school's teaching-system address. + +The bundled CourseSchedule directory exposes 2,682 one-tap routes after its +built-in replacements. Another 764 displayed rows now map to a local parser +but lack a loadable URL. They use the same generic flow and ask for a +user-confirmed school URL. The newly recovered routes include 32 `suda_post`, +13 `zju_post`, seven `xju_post`, six `cupl_post`, five `hit`, four `scau`, four +`hitsz`, two `xhtd`, two `gdei`, one `uestc_post`, and one `swjtu_post` row. +The remaining 120 rows stay non-actionable: 104 are `ziyan` remote-parser rows, +15 still need school-specific DOM or JSON/fetch capture, and one `login` row +has no parser dispatch evidence. + +Reference URLs are shown only as historical guidance. They are never loaded +automatically, because many are private IPs, stale endpoints, or bare WebVPN +portals rather than timetable resources. diff --git a/analysis/ziyan-url-recovery-20260908/README.md b/analysis/ziyan-url-recovery-20260908/README.md new file mode 100644 index 0000000..dd1d2ca --- /dev/null +++ b/analysis/ziyan-url-recovery-20260908/README.md @@ -0,0 +1,37 @@ +# Ziyan school URL recovery + +Audit workspace for the 222 bundled `ziyan` entries that currently have no +locally loadable timetable URL. Only school-controlled or otherwise +independently verifiable entry points are eligible for the application catalog. + +Status: second-pass recovery applied. Of 1,088 `ziyan` rows, 866 already had a +safe candidate URL in the bundled source and 118 more were recovered here. The +984 reviewed rows now use the app's experimental on-device parsers; 104 rows +remain adapter requests because no reliable public-domain entry was confirmed. +Those 104 references currently break down into 57 bare public-IP endpoints, +29 private-network endpoints, 10 VPN/zero-trust-only routes, 7 missing or +placeholder values, and 1 other unverified reference. + +Sources are kept separate from the application catalog until an entry point is +verified. `fetch_official_homepages.ps1` joins the 222 records to the MIT-licensed +EduCN school-homepage dataset; `collect_official_links.ps1` then inspects links +published by those homepages for teaching, portal, and identity-system entry +points. Search-engine candidates are evidence for follow-up only. + +`probe_official_candidates.ps1` checks the externally addressable candidates +published by official homepages and records redirects, page titles, and local +parser fingerprints without storing response bodies. + +`collect_department_links.ps1` follows only short, explicit teaching-office +links from the official homepage and searches those pages for a direct system +entry point. + +`discover_same_domain_hosts.ps1` resolves common teaching/portal subdomains +under the same institutional domain and filters wildcard DNS answers. +`probe_same_domain_hosts.ps1` then inspects only those non-wildcard hosts. + +`verified_urls.json` is the auditable 118-row enabled decision set. Twelve rows +also declare the exact reviewed authentication scope needed for their login +redirect. The set otherwise excludes private addresses, bare public IPs, +generic VPN login roots, and informational pages. `apply_verified_urls.ps1` +applies that set without replacing a different existing URL. diff --git a/analysis/ziyan-url-recovery-20260908/apply_verified_urls.ps1 b/analysis/ziyan-url-recovery-20260908/apply_verified_urls.ps1 new file mode 100644 index 0000000..651310a --- /dev/null +++ b/analysis/ziyan-url-recovery-20260908/apply_verified_urls.ps1 @@ -0,0 +1,64 @@ +param( + [string]$CatalogPath = "" +) + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +if ([string]::IsNullOrWhiteSpace($CatalogPath)) { + $CatalogPath = Join-Path $repoRoot "app\src\main\assets\academic_school_directory.json" +} + +$catalog = Get-Content -Raw -LiteralPath $CatalogPath | ConvertFrom-Json +$mappings = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot "verified_urls.json") | ConvertFrom-Json +$seen = @{} +$applied = 0 + +foreach ($mapping in $mappings) { + if ($seen.ContainsKey($mapping.id)) { + throw "Duplicate mapping id: $($mapping.id)" + } + $seen[$mapping.id] = $true + + $matches = @($catalog.entries | Where-Object { $_.id -eq $mapping.id }) + if ($matches.Count -ne 1) { + throw "Expected one catalog entry for $($mapping.id), got $($matches.Count)" + } + $entry = $matches[0] + if ($entry.name -ne $mapping.name -or $entry.sourceType -ne "ziyan") { + throw "Catalog identity mismatch for $($mapping.id)" + } + if ($mapping.enabled -eq $false) { + $entry.profile = $null + $entry.url = "" + $entry.support = "adapter_required" + $entry.cleartext = $false + $entry.PSObject.Properties.Remove("authenticationUrls") + continue + } + if (-not [string]::IsNullOrWhiteSpace([string]$entry.url) -and $entry.url -ne $mapping.url) { + throw "Refusing to replace an existing URL for $($mapping.id)" + } + + $entry.profile = $mapping.profile + $entry.url = $mapping.url + $entry.support = "experimental" + $entry.cleartext = $mapping.url.StartsWith("http://", [System.StringComparison]::OrdinalIgnoreCase) + $entry.PSObject.Properties.Remove("authenticationUrls") + [object[]]$authenticationUrls = if ($mapping.PSObject.Properties.Name -contains "authenticationUrls") { + @($mapping.authenticationUrls | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) + } else { + @() + } + if ($authenticationUrls.Count -gt 0) { + $entry | Add-Member -NotePropertyName authenticationUrls -NotePropertyValue ([object[]]$authenticationUrls) + } + $applied++ +} + +$json = $catalog | ConvertTo-Json -Depth 20 -Compress +[System.IO.File]::WriteAllText( + (Resolve-Path -LiteralPath $CatalogPath).Path, + $json, + [System.Text.UTF8Encoding]::new($false) +) + +Write-Output "Applied $applied reviewed local-entry mappings; $($mappings.Count - $applied) rejected after review." diff --git a/analysis/ziyan-url-recovery-20260908/collect_candidates.ps1 b/analysis/ziyan-url-recovery-20260908/collect_candidates.ps1 new file mode 100644 index 0000000..a9fb7bd --- /dev/null +++ b/analysis/ziyan-url-recovery-20260908/collect_candidates.ps1 @@ -0,0 +1,55 @@ +param( + [Parameter(Mandatory = $true)] + [string]$AssetPath, + [Parameter(Mandatory = $true)] + [string]$OutputPath +) + +$ErrorActionPreference = 'Stop' +$headers = @{ 'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/122 Safari/537.36' } +$blockedHosts = '(?i)(baidu|bing|brave|sohu|zhihu|sogou|360|sulaixue|dakao8|kao900|gaoxiaoji|xsy-edu|zhila|wikipedia|douyin|bilibili|weixin|qq|csdn|toutiao|163|so\.com)$' + +$catalog = Get-Content -LiteralPath $AssetPath -Raw | ConvertFrom-Json +$entries = @($catalog.entries | Where-Object { $_.sourceType -eq 'ziyan' -and -not $_.url }) +$results = [System.Collections.Generic.List[object]]::new() + +for ($index = 0; $index -lt $entries.Count; $index++) { + $entry = $entries[$index] + $query = [uri]::EscapeDataString('"' + $entry.name + '" 教务系统 登录 官方') + $searchUrl = "https://search.brave.com/search?q=$query&source=web" + $candidates = @() + $errorText = '' + try { + $response = Invoke-WebRequest -Uri $searchUrl -UseBasicParsing -Headers $headers -TimeoutSec 25 + $seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $candidates = @($response.Links | ForEach-Object { + $href = [string]$_.href + if ($href -notmatch '^https?://') { return } + try { $uri = [uri]$href } catch { return } + $candidateHost = $uri.Host.ToLowerInvariant() + $baseHost = ($candidateHost -replace '^www\.', '') + if (-not $candidateHost -or $baseHost -match $blockedHosts -or -not $seen.Add($href)) { return } + $titleMatch = [regex]::Match([string]$_.outerHTML, ']+class="[^"]*title[^"]*"[^>]+title="([^"]+)"', 'IgnoreCase') + $title = if ($titleMatch.Success) { [System.Net.WebUtility]::HtmlDecode($titleMatch.Groups[1].Value) } else { '' } + [pscustomobject]@{ url = $href; title = $title; host = $candidateHost } + } | Select-Object -First 12) + } catch { + $errorText = $_.Exception.Message + } + + $results.Add([pscustomobject]@{ + id = $entry.id + name = $entry.name + category = $entry.category + referenceUrl = [string]$entry.referenceUrl + candidates = $candidates + searchError = $errorText + }) + + if (($index + 1) % 20 -eq 0 -or $index + 1 -eq $entries.Count) { + Write-Output ("Searched {0}/{1}" -f ($index + 1), $entries.Count) + } + Start-Sleep -Milliseconds 900 +} + +$results | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutputPath -Encoding UTF8 diff --git a/analysis/ziyan-url-recovery-20260908/collect_candidates_sm.ps1 b/analysis/ziyan-url-recovery-20260908/collect_candidates_sm.ps1 new file mode 100644 index 0000000..c69f73a --- /dev/null +++ b/analysis/ziyan-url-recovery-20260908/collect_candidates_sm.ps1 @@ -0,0 +1,55 @@ +param( + [Parameter(Mandatory = $true)] + [string]$AssetPath, + [Parameter(Mandatory = $true)] + [string]$OutputPath +) + +$ErrorActionPreference = 'Stop' +$headers = @{ 'User-Agent' = 'Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 Chrome/122 Mobile Safari/537.36' } +$blockedHosts = '(?i)(^|\.)(sm|uc|baidu|bing|sogou|sohu|zhihu|sulaixue|creditsailing|dakao8|kao900|gaoxiaoji|xsy-edu|zhila|wikipedia|douyin|bilibili|weixin|qq|csdn|toutiao|163)\.(com|cn|net|org)$' + +$catalog = Get-Content -LiteralPath $AssetPath -Raw | ConvertFrom-Json +$entries = @($catalog.entries | Where-Object { $_.sourceType -eq 'ziyan' -and -not $_.url }) +$results = [System.Collections.Generic.List[object]]::new() + +for ($index = 0; $index -lt $entries.Count; $index++) { + $entry = $entries[$index] + $query = [uri]::EscapeDataString($entry.name + ' 教务系统 登录') + $searchUrl = "https://m.sm.cn/s?q=$query" + $candidates = @() + $errorText = '' + try { + $response = Invoke-WebRequest -Uri $searchUrl -UseBasicParsing -Headers $headers -TimeoutSec 25 + $seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $candidates = @($response.Links | ForEach-Object { + $href = [System.Net.WebUtility]::HtmlDecode([string]$_.href) + if ($href -notmatch '^https?://') { return } + try { $uri = [uri]$href } catch { return } + $candidateHost = $uri.Host.ToLowerInvariant() + if (-not $candidateHost -or $candidateHost -match $blockedHosts -or -not $seen.Add($href)) { return } + $title = [System.Net.WebUtility]::HtmlDecode( + [regex]::Replace([string]$_.outerHTML, '<[^>]+>', '') + ).Trim() + [pscustomobject]@{ url = $href; title = $title; host = $candidateHost } + } | Select-Object -First 16) + } catch { + $errorText = $_.Exception.Message + } + + $results.Add([pscustomobject]@{ + id = $entry.id + name = $entry.name + category = $entry.category + referenceUrl = [string]$entry.referenceUrl + candidates = $candidates + searchError = $errorText + }) + + if (($index + 1) % 20 -eq 0 -or $index + 1 -eq $entries.Count) { + Write-Output ("Searched {0}/{1}" -f ($index + 1), $entries.Count) + } + Start-Sleep -Milliseconds 1000 +} + +$results | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutputPath -Encoding UTF8 diff --git a/analysis/ziyan-url-recovery-20260908/collect_candidates_so.ps1 b/analysis/ziyan-url-recovery-20260908/collect_candidates_so.ps1 new file mode 100644 index 0000000..c7cb09b --- /dev/null +++ b/analysis/ziyan-url-recovery-20260908/collect_candidates_so.ps1 @@ -0,0 +1,54 @@ +param( + [Parameter(Mandatory = $true)] + [string]$AssetPath, + [Parameter(Mandatory = $true)] + [string]$OutputPath +) + +$ErrorActionPreference = 'Stop' +$headers = @{ 'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/122 Safari/537.36' } +$blockedHosts = '(?i)(^|\.)(so|360|baidu|bing|sogou|sohu|zhihu|sulaixue|dakao8|kao900|gaoxiaoji|xsy-edu|zhila|wikipedia|douyin|bilibili|weixin|qq|csdn|toutiao|163)\.(com|cn|net|org)$' + +$catalog = Get-Content -LiteralPath $AssetPath -Raw | ConvertFrom-Json +$entries = @($catalog.entries | Where-Object { $_.sourceType -eq 'ziyan' -and -not $_.url }) +$results = [System.Collections.Generic.List[object]]::new() + +for ($index = 0; $index -lt $entries.Count; $index++) { + $entry = $entries[$index] + $query = [uri]::EscapeDataString('"' + $entry.name + '" 教务系统 登录') + $searchUrl = "https://www.so.com/s?q=$query" + $candidates = @() + $errorText = '' + try { + $response = Invoke-WebRequest -Uri $searchUrl -UseBasicParsing -AllowInsecureRedirect -Headers $headers -TimeoutSec 25 + $seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $candidates = @($response.Links | ForEach-Object { + $href = [System.Net.WebUtility]::HtmlDecode([string]$_.'data-mdurl') + if ($href -notmatch '^https?://') { return } + try { $uri = [uri]$href } catch { return } + $candidateHost = $uri.Host.ToLowerInvariant() + if (-not $candidateHost -or $candidateHost -match $blockedHosts -or -not $seen.Add($href)) { return } + $rawTitle = [regex]::Match([string]$_.outerHTML, '>(.*?)', 'IgnoreCase,Singleline').Groups[1].Value + $title = [System.Net.WebUtility]::HtmlDecode([regex]::Replace($rawTitle, '<[^>]+>', '')).Trim() + [pscustomobject]@{ url = $href; title = $title; host = $candidateHost } + } | Select-Object -First 12) + } catch { + $errorText = $_.Exception.Message + } + + $results.Add([pscustomobject]@{ + id = $entry.id + name = $entry.name + category = $entry.category + referenceUrl = [string]$entry.referenceUrl + candidates = $candidates + searchError = $errorText + }) + + if (($index + 1) % 20 -eq 0 -or $index + 1 -eq $entries.Count) { + Write-Output ("Searched {0}/{1}" -f ($index + 1), $entries.Count) + } + Start-Sleep -Milliseconds 1200 +} + +$results | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutputPath -Encoding UTF8 diff --git a/analysis/ziyan-url-recovery-20260908/collect_department_links.ps1 b/analysis/ziyan-url-recovery-20260908/collect_department_links.ps1 new file mode 100644 index 0000000..c930cd8 --- /dev/null +++ b/analysis/ziyan-url-recovery-20260908/collect_department_links.ps1 @@ -0,0 +1,85 @@ +param( + [Parameter(Mandatory = $true)] + [string]$InputDirectory, + [Parameter(Mandatory = $true)] + [string]$OutputPath, + [int]$ThrottleLimit = 8 +) + +$ErrorActionPreference = 'Stop' +$records = @(Get-ChildItem -LiteralPath $InputDirectory -Filter 'official_links_*.json' | + Sort-Object Name | + ForEach-Object { Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json }) + +$departments = foreach ($record in $records) { + foreach ($link in @($record.links)) { + if ([string]$link.text -notmatch '^(教务处|教务部|教学管理|教务管理|教学工作处|教务科研处|教科处|教务与科研处)(\s*\|.*)?$') { continue } + try { $uri = [uri][string]$link.url } catch { continue } + if ($uri.Scheme -notin @('http', 'https')) { continue } + [pscustomobject]@{ + index = [int]$record.index + id = [string]$record.id + name = [string]$record.name + pageUrl = [string]$link.url + pageText = [string]$link.text + } + } +} + +$results = $departments | ForEach-Object -Parallel { + $source = $_ + $found = [System.Collections.Generic.List[object]]::new() + $errorText = '' + $statusCode = 0 + $finalUrl = '' + try { + $headers = @{ + 'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/128 Safari/537.36' + 'Accept-Language' = 'zh-CN,zh;q=0.9' + } + $response = Invoke-WebRequest -Uri ([string]$source.pageUrl) -UseBasicParsing -AllowInsecureRedirect -SkipCertificateCheck -MaximumRedirection 6 -TimeoutSec 22 -Headers $headers + $statusCode = [int]$response.StatusCode + $finalUrl = [string]$response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri + $baseUri = [uri]$finalUrl + $seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $anchors = [regex]::Matches($response.Content, ']*href=["''](?[^"'']+)["''][^>]*>(?[\s\S]*?)', 'IgnoreCase') + foreach ($anchor in $anchors) { + $rawHref = [System.Net.WebUtility]::HtmlDecode($anchor.Groups['href'].Value).Trim() + if (-not $rawHref -or $rawHref -match '^(javascript:|mailto:|tel:|#)') { continue } + try { $resolved = [uri]::new($baseUri, $rawHref) } catch { continue } + if ($resolved.Scheme -notin @('http', 'https')) { continue } + $label = [regex]::Replace($anchor.Groups['text'].Value, '<[^>]+>', ' ') + $label = [System.Net.WebUtility]::HtmlDecode($label) + $label = ($label -replace '\s+', ' ').Trim() + $url = $resolved.AbsoluteUri + $score = 0 + if ($label -match '教务系统|教务管理系统|教务平台|学生入口|信息门户|智慧校园|数字校园|统一身份|师生登录|校园门户|融合门户|融校园') { $score += 12 } + if ($url -match '(?i)(^|[./_-])(jw|jwxt|jwgl|jwc|academic|student|portal|cas|authserver|ehall|e-hall|my)([./_:-]|$)') { $score += 7 } + if ($url -match '(?i)(login|slogin|default2?\.aspx|home\.aspx|jsxsd|jwweb|jwglxt|eams|lyuapServer)') { $score += 6 } + if ($score -ge 12 -and $seen.Add($url)) { + $found.Add([pscustomobject]@{ url = $url; text = $label; score = $score }) + } + } + } catch { + $errorText = $_.Exception.Message + if ($_.Exception.Response -and $_.Exception.Response.RequestMessage) { + $finalUrl = [string]$_.Exception.Response.RequestMessage.RequestUri.AbsoluteUri + } + } + + [pscustomobject]@{ + index = [int]$source.index + id = [string]$source.id + name = [string]$source.name + pageText = [string]$source.pageText + pageUrl = [string]$source.pageUrl + finalUrl = $finalUrl + statusCode = $statusCode + links = @($found | Sort-Object score -Descending | Select-Object -First 20) + error = $errorText + } +} -ThrottleLimit $ThrottleLimit + +$results = @($results | Sort-Object index, pageUrl) +$results | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutputPath -Encoding UTF8 +Write-Output ("Crawled {0} teaching-office pages for {1} schools; {2} pages exposed candidate entry points." -f $results.Count, @($results | Select-Object -ExpandProperty index -Unique).Count, @($results | Where-Object { $_.links.Count -gt 0 }).Count) diff --git a/analysis/ziyan-url-recovery-20260908/collect_official_links.ps1 b/analysis/ziyan-url-recovery-20260908/collect_official_links.ps1 new file mode 100644 index 0000000..1056836 --- /dev/null +++ b/analysis/ziyan-url-recovery-20260908/collect_official_links.ps1 @@ -0,0 +1,98 @@ +param( + [Parameter(Mandatory = $true)] + [string]$InputPath, + [Parameter(Mandatory = $true)] + [string]$OutputPath, + [int]$StartIndex = 0, + [int]$Count = 60, + [int]$ThrottleLimit = 8 +) + +$ErrorActionPreference = 'Stop' +$entries = @(Get-Content -LiteralPath $InputPath -Raw | ConvertFrom-Json) +$selection = @($entries | Where-Object { $_.index -ge $StartIndex } | Select-Object -First $Count) + +$results = $selection | ForEach-Object -Parallel { + $entry = $_ + $links = [System.Collections.Generic.List[object]]::new() + $errorText = '' + $statusCode = 0 + $finalUrl = '' + + if ([string]::IsNullOrWhiteSpace([string]$entry.officialUrl)) { + $errorText = 'No official homepage is listed.' + } else { + try { + $headers = @{ + 'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/128 Safari/537.36' + 'Accept-Language' = 'zh-CN,zh;q=0.9' + } + $response = Invoke-WebRequest -Uri ([string]$entry.officialUrl) -UseBasicParsing -AllowInsecureRedirect -SkipCertificateCheck -MaximumRedirection 6 -TimeoutSec 20 -Headers $headers + $statusCode = [int]$response.StatusCode + $finalUrl = [string]$response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri + $baseUri = [uri]$finalUrl + $seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $anchors = [regex]::Matches($response.Content, ']*href=["''](?[^"'']+)["''][^>]*>(?[\s\S]*?)', 'IgnoreCase') + + foreach ($anchor in $anchors) { + $rawHref = [System.Net.WebUtility]::HtmlDecode($anchor.Groups['href'].Value).Trim() + if (-not $rawHref -or $rawHref -match '^(javascript:|mailto:|tel:|#)') { continue } + try { $resolved = [uri]::new($baseUri, $rawHref) } catch { continue } + if ($resolved.Scheme -notin @('http', 'https')) { continue } + + $label = [regex]::Replace($anchor.Groups['text'].Value, '<[^>]+>', ' ') + $label = [System.Net.WebUtility]::HtmlDecode($label) + $label = ($label -replace '\s+', ' ').Trim() + $url = $resolved.AbsoluteUri + $score = 0 + $reasons = [System.Collections.Generic.List[string]]::new() + + if ($label -match '教务系统|教务管理|教务平台|教务入口|学生入口|学生平台|信息门户|智慧校园|数字校园|统一身份|师生登录|校园门户|融合门户|融校园') { + $score += 12 + $reasons.Add('anchor-login') + } elseif ($label -match '教务处|教学管理|学生服务|网上办事|校内服务|快速通道') { + $score += 4 + $reasons.Add('anchor-related') + } + + if ($url -match '(?i)(^|[./_-])(jw|jwxt|jwgl|jwc|academic|student|portal|cas|authserver|ehall|e-hall|my|vpn)([./_:-]|$)') { + $score += 7 + $reasons.Add('url-pattern') + } + if ($url -match '(?i)(login|slogin|default2?\.aspx|home\.aspx|jsxsd|jwweb|jwglxt|eams|lyuapServer)') { + $score += 6 + $reasons.Add('login-pattern') + } + + if ($score -gt 0 -and $seen.Add($url)) { + $links.Add([pscustomobject]@{ + url = $url + text = $label + score = $score + reasons = @($reasons) + }) + } + } + } catch { + $errorText = $_.Exception.Message + if ($_.Exception.Response -and $_.Exception.Response.RequestMessage) { + $finalUrl = [string]$_.Exception.Response.RequestMessage.RequestUri.AbsoluteUri + } + } + } + + [pscustomobject]@{ + index = [int]$entry.index + id = [string]$entry.id + name = [string]$entry.name + officialUrl = [string]$entry.officialUrl + finalUrl = $finalUrl + statusCode = $statusCode + links = @($links | Sort-Object score -Descending | Select-Object -First 20) + error = $errorText + } +} -ThrottleLimit $ThrottleLimit + +$results = @($results | Sort-Object index) +$results | ConvertTo-Json -Depth 7 | Set-Content -LiteralPath $OutputPath -Encoding UTF8 +Write-Output ("Crawled {0} homepages; {1} returned candidate links; {2} failed or had no homepage." -f $results.Count, @($results | Where-Object { $_.links.Count -gt 0 }).Count, @($results | Where-Object error).Count) diff --git a/analysis/ziyan-url-recovery-20260908/discover_same_domain_hosts.ps1 b/analysis/ziyan-url-recovery-20260908/discover_same_domain_hosts.ps1 new file mode 100644 index 0000000..f3cb122 --- /dev/null +++ b/analysis/ziyan-url-recovery-20260908/discover_same_domain_hosts.ps1 @@ -0,0 +1,93 @@ +param( + [Parameter(Mandatory = $true)] + [string]$InputPath, + [Parameter(Mandatory = $true)] + [string]$OutputPath, + [int]$ThrottleLimit = 64 +) + +$ErrorActionPreference = 'Stop' +$entries = @(Get-Content -LiteralPath $InputPath -Raw | ConvertFrom-Json | Where-Object officialUrl) +$prefixes = @( + 'jw', 'jwxt', 'jwgl', 'jwc', 'portal', 'cas', 'authserver', 'ehall', 'my', 'ids', + 'sso', 'uap', 'tyrz', 'i', 'one', 'smart', 'zhxy', 'jwapp', 'jiaowu', 'jwnew', + 'xk', 'xkxt', 'dean', 'student', 'service', 'urp', + 'jw1', 'jw2', 'jw3', 'jwg', 'jwmis', 'jws', 'jwweb', 'jwglxt', 'jwcxt', + 'jwa', 'jwb', 'edu', 'eams', 'xjw', 'xjwgl', 'xjwxt', 'xsjw', 'xsjwxt', + 'zfjw', 'zf', 'jxgl', 'jxt', 'jxjw', 'jwmh', 'hall', 'bsdt', 'campus', + 'digital', 'center', 'web', 'login', 'identity', 'id', 'auth', 'oauth', + 'graduate', 'yjs', 'yjsy', 'yjsgl', 'post', 'gmis', 'webvpn' +) + +function Get-RootDomain([string]$hostName) { + $labels = @($hostName.Trim('.').ToLowerInvariant().Split('.')) + if ($labels.Count -lt 2) { return $hostName } + if ($labels.Count -ge 3 -and ($labels[-2] + '.' + $labels[-1]) -in @('edu.cn', 'com.cn', 'net.cn', 'org.cn')) { + return ($labels[-3..-1] -join '.') + } + return ($labels[-2..-1] -join '.') +} + +$schools = foreach ($entry in $entries) { + try { $homepageUri = [uri][string]$entry.officialUrl } catch { continue } + if (-not $homepageUri.Host) { continue } + [pscustomobject]@{ + index = [int]$entry.index + id = [string]$entry.id + name = [string]$entry.name + rootDomain = Get-RootDomain $homepageUri.Host + } +} + +$roots = @($schools.rootDomain | Sort-Object -Unique) +$hostTasks = foreach ($root in $roots) { + [pscustomobject]@{ rootDomain = $root; prefix = '_wildcard'; host = "no-such-courseschedule-probe.$root" } + foreach ($prefix in $prefixes) { + [pscustomobject]@{ rootDomain = $root; prefix = $prefix; host = "$prefix.$root" } + } +} + +$resolved = $hostTasks | ForEach-Object -Parallel { + $task = $_ + $addresses = @() + $errorText = '' + try { + $answer = [System.Net.Dns]::GetHostAddressesAsync([string]$task.host).WaitAsync([TimeSpan]::FromSeconds(4)).Result + $addresses = @($answer.IPAddressToString | Sort-Object -Unique) + } catch { + $errorText = $_.Exception.GetType().Name + } + [pscustomobject]@{ + rootDomain = [string]$task.rootDomain + prefix = [string]$task.prefix + host = [string]$task.host + addresses = $addresses + error = $errorText + } +} -ThrottleLimit $ThrottleLimit + +$wildcards = @{} +foreach ($row in @($resolved | Where-Object prefix -eq '_wildcard')) { + $wildcards[$row.rootDomain] = @($row.addresses) -join ',' +} + +$result = foreach ($row in @($resolved | Where-Object prefix -ne '_wildcard')) { + if ($row.addresses.Count -eq 0) { continue } + $addressKey = @($row.addresses) -join ',' + foreach ($school in @($schools | Where-Object rootDomain -eq $row.rootDomain)) { + [pscustomobject]@{ + index = [int]$school.index + id = [string]$school.id + name = [string]$school.name + rootDomain = [string]$row.rootDomain + prefix = [string]$row.prefix + host = [string]$row.host + addresses = @($row.addresses) + wildcardMatch = ($wildcards[$row.rootDomain] -and $wildcards[$row.rootDomain] -eq $addressKey) + } + } +} + +$result = @($result | Sort-Object index, prefix) +$result | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $OutputPath -Encoding UTF8 +Write-Output ("Resolved {0} common subdomains for {1} schools; {2} differ from each root's wildcard answer." -f $result.Count, @($result.index | Sort-Object -Unique).Count, @($result | Where-Object { -not $_.wildcardMatch }).Count) diff --git a/analysis/ziyan-url-recovery-20260908/fetch_official_homepages.ps1 b/analysis/ziyan-url-recovery-20260908/fetch_official_homepages.ps1 new file mode 100644 index 0000000..75bc0ac --- /dev/null +++ b/analysis/ziyan-url-recovery-20260908/fetch_official_homepages.ps1 @@ -0,0 +1,66 @@ +param( + [Parameter(Mandatory = $true)] + [string]$AssetPath, + [Parameter(Mandatory = $true)] + [string]$OutputPath +) + +$ErrorActionPreference = 'Stop' +$dataset = 'RenzhiMind%2FEduCN' +$headers = @{ 'User-Agent' = 'CourseSchedule-directory-research/1.0' } +$rows = [System.Collections.Generic.List[object]]::new() + +foreach ($split in @( + @{ Name = 'general'; Count = 2919 }, + @{ Name = 'adult'; Count = 248 } +)) { + for ($offset = 0; $offset -lt $split.Count; $offset += 100) { + $apiUrl = "https://datasets-server.huggingface.co/rows?dataset=$dataset&config=default&split=$($split.Name)&offset=$offset&length=100" + $response = Invoke-RestMethod -Uri $apiUrl -Headers $headers -TimeoutSec 45 + foreach ($item in $response.rows) { + $rows.Add($item.row) + } + } +} + +$byName = @{} +foreach ($row in $rows) { + $byName[[string]$row.'学校名称'] = $row +} + +$aliases = @{ + '东北石油大学 - 研究生' = '东北石油大学' + '甘肃警察职业学院' = '甘肃警察学院' + '邢台医学院(原邢台医学高等专科学校)' = '邢台医学院' +} + +$catalog = Get-Content -LiteralPath $AssetPath -Raw | ConvertFrom-Json +$entries = @($catalog.entries | Where-Object { $_.sourceType -eq 'ziyan' -and -not $_.url }) +$result = for ($index = 0; $index -lt $entries.Count; $index++) { + $entry = $entries[$index] + $datasetName = if ($aliases.ContainsKey([string]$entry.name)) { + $aliases[[string]$entry.name] + } else { + [string]$entry.name + } + $row = $byName[$datasetName] + $officialUrl = if ($null -ne $row -and [string]$row.'学校官网' -ne '-') { + [string]$row.'学校官网' + } else { + '' + } + + [pscustomobject]@{ + index = $index + id = [string]$entry.id + name = [string]$entry.name + category = [string]$entry.category + datasetName = $datasetName + schoolCode = if ($null -ne $row) { [string]$row.'学校标识码' } else { '' } + officialUrl = $officialUrl + referenceUrl = [string]$entry.referenceUrl + } +} + +$result | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $OutputPath -Encoding UTF8 +Write-Output ("Matched {0}/{1} entries to the official-homepage dataset ({2} usable URLs)." -f @($result | Where-Object schoolCode).Count, $entries.Count, @($result | Where-Object officialUrl).Count) diff --git a/analysis/ziyan-url-recovery-20260908/probe_official_candidates.ps1 b/analysis/ziyan-url-recovery-20260908/probe_official_candidates.ps1 new file mode 100644 index 0000000..7630b03 --- /dev/null +++ b/analysis/ziyan-url-recovery-20260908/probe_official_candidates.ps1 @@ -0,0 +1,107 @@ +param( + [Parameter(Mandatory = $true)] + [string]$InputDirectory, + [Parameter(Mandatory = $true)] + [string]$OutputPath, + [int]$ThrottleLimit = 8 +) + +$ErrorActionPreference = 'Stop' +$records = @(Get-ChildItem -LiteralPath $InputDirectory -Filter 'official_links_*.json' | + Sort-Object Name | + ForEach-Object { Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json }) +$candidateLabels = '教务系统|教务管理系统|教务管理平台|教务平台|教务管理网|学生入口|统一身份认证|校园门户|信息门户|融合门户|融校园|智慧校园平台|数字校园平台' +$blockedHosts = '(?i)(^|\.)(baidu|bing|so|sogou|360|video)\.(com|cn|net)$' + +$candidates = foreach ($record in $records) { + foreach ($link in @($record.links)) { + if ([int]$link.score -lt 12 -or [string]$link.text -notmatch $candidateLabels) { continue } + try { $uri = [uri][string]$link.url } catch { continue } + $candidateHost = $uri.Host.ToLowerInvariant() + if (-not $candidateHost -or $candidateHost -match $blockedHosts) { continue } + if ($candidateHost -match '^[0-9.]+$' -or $candidateHost -match '^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)') { continue } + + [pscustomobject]@{ + index = [int]$record.index + id = [string]$record.id + name = [string]$record.name + officialUrl = [string]$record.officialUrl + sourceText = [string]$link.text + sourceScore = [int]$link.score + url = [string]$link.url + } + } +} + +$results = $candidates | ForEach-Object -Parallel { + $candidate = $_ + $statusCode = 0 + $finalUrl = '' + $title = '' + $contentLength = 0 + $profile = 'topology' + $signals = [System.Collections.Generic.List[string]]::new() + $errorText = '' + + try { + $headers = @{ + 'User-Agent' = 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/128 Mobile Safari/537.36' + 'Accept-Language' = 'zh-CN,zh;q=0.9' + } + $response = Invoke-WebRequest -Uri ([string]$candidate.url) -UseBasicParsing -AllowInsecureRedirect -SkipCertificateCheck -MaximumRedirection 8 -TimeoutSec 25 -Headers $headers + $statusCode = [int]$response.StatusCode + $finalUrl = [string]$response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri + $contentLength = [int]$response.Content.Length + $titleMatch = [regex]::Match($response.Content, ']*>(?[\s\S]*?)', 'IgnoreCase') + $title = [System.Net.WebUtility]::HtmlDecode([regex]::Replace($titleMatch.Groups['title'].Value, '<[^>]+>', ' ')) + $title = ($title -replace '\s+', ' ').Trim() + $haystack = ([string]$candidate.url + "`n" + $finalUrl + "`n" + $title + "`n" + $response.Content) + + if ($haystack -match '(?i)(jwglxt|xtgl/login_slogin|正方(软件|教务)|zftal)') { + $profile = 'zhengfang' + $signals.Add('zhengfang') + } elseif ($haystack -match '(?i)(jsxsd|强智(科技|教务)|湖南强智)') { + $profile = 'qiangzhi' + $signals.Add('qiangzhi') + } elseif ($haystack -match '(?i)(jwweb|青果(软件|教务)|kingosoft)') { + $profile = 'kingosoft_new' + $signals.Add('kingosoft') + } elseif ($haystack -match '(?i)(/eams(?:/|\b)|EAMS教务)') { + $profile = 'eams' + $signals.Add('eams') + } elseif ($haystack -match '(?i)(\.jw\.chaoxing\.com|超星教务)') { + $profile = 'chaoxing' + $signals.Add('chaoxing') + } + + if ($haystack -match '(?i)(login|登录|统一身份|cas|authserver|lyuapServer|教务)') { $signals.Add('login-page') } + if ($haystack -match '(?i)(课表|课程表|教学安排|我的课表)') { $signals.Add('timetable-copy') } + } catch { + $errorText = $_.Exception.Message + if ($_.Exception.Response -and $_.Exception.Response.RequestMessage) { + $finalUrl = [string]$_.Exception.Response.RequestMessage.RequestUri.AbsoluteUri + try { $statusCode = [int]$_.Exception.Response.StatusCode } catch { } + } + } + + [pscustomobject]@{ + index = [int]$candidate.index + id = [string]$candidate.id + name = [string]$candidate.name + officialUrl = [string]$candidate.officialUrl + sourceText = [string]$candidate.sourceText + sourceScore = [int]$candidate.sourceScore + url = [string]$candidate.url + statusCode = $statusCode + finalUrl = $finalUrl + title = $title + contentLength = $contentLength + profile = $profile + signals = @($signals) + error = $errorText + } +} -ThrottleLimit $ThrottleLimit + +$results = @($results | Sort-Object index, @{ Expression = 'sourceScore'; Descending = $true }) +$results | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutputPath -Encoding UTF8 +Write-Output ("Probed {0} homepage-published candidates: {1} reached HTTP 2xx, {2} exposed a known platform fingerprint." -f $results.Count, @($results | Where-Object { $_.statusCode -ge 200 -and $_.statusCode -lt 300 }).Count, @($results | Where-Object { $_.profile -ne 'topology' }).Count) diff --git a/analysis/ziyan-url-recovery-20260908/probe_same_domain_hosts.ps1 b/analysis/ziyan-url-recovery-20260908/probe_same_domain_hosts.ps1 new file mode 100644 index 0000000..a38cbff --- /dev/null +++ b/analysis/ziyan-url-recovery-20260908/probe_same_domain_hosts.ps1 @@ -0,0 +1,102 @@ +param( + [Parameter(Mandatory = $true)] + [string]$InputPath, + [Parameter(Mandatory = $true)] + [string]$OutputPath, + [int]$ThrottleLimit = 20 +) + +$ErrorActionPreference = 'Stop' +$records = @(Get-Content -LiteralPath $InputPath -Raw | ConvertFrom-Json | Where-Object { -not $_.wildcardMatch }) +$uniqueHosts = @($records | Group-Object host | ForEach-Object { $_.Group[0] }) + +$probes = $uniqueHosts | ForEach-Object -Parallel { + $candidate = $_ + $statusCode = 0 + $requestedUrl = '' + $finalUrl = '' + $title = '' + $contentLength = 0 + $profile = 'topology' + $signals = [System.Collections.Generic.List[string]]::new() + $errorText = '' + + foreach ($scheme in @('https', 'http')) { + $requestedUrl = "${scheme}://$($candidate.host)/" + try { + $headers = @{ + 'User-Agent' = 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/128 Mobile Safari/537.36' + 'Accept-Language' = 'zh-CN,zh;q=0.9' + } + $response = Invoke-WebRequest -Uri $requestedUrl -UseBasicParsing -AllowInsecureRedirect -SkipCertificateCheck -MaximumRedirection 8 -TimeoutSec 15 -Headers $headers + $statusCode = [int]$response.StatusCode + $finalUrl = [string]$response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri + $contentLength = [int]$response.Content.Length + $titleMatch = [regex]::Match($response.Content, ']*>(?[\s\S]*?)', 'IgnoreCase') + $title = [System.Net.WebUtility]::HtmlDecode([regex]::Replace($titleMatch.Groups['title'].Value, '<[^>]+>', ' ')) + $title = ($title -replace '\s+', ' ').Trim() + $haystack = ($requestedUrl + "`n" + $finalUrl + "`n" + $title + "`n" + $response.Content) + if ($haystack -match '(?i)(jwglxt|xtgl/login_slogin|正方(软件|教务)|zftal)') { + $profile = 'zhengfang'; $signals.Add('zhengfang') + } elseif ($haystack -match '(?i)(jsxsd|强智(科技|教务)|湖南强智)') { + $profile = 'qiangzhi'; $signals.Add('qiangzhi') + } elseif ($haystack -match '(?i)(jwweb|青果(软件|教务)|kingosoft)') { + $profile = 'kingosoft_new'; $signals.Add('kingosoft') + } elseif ($haystack -match '(?i)(/eams(?:/|\b)|EAMS教务)') { + $profile = 'eams'; $signals.Add('eams') + } elseif ($haystack -match '(?i)(\.jw\.chaoxing\.com|超星教务)') { + $profile = 'chaoxing'; $signals.Add('chaoxing') + } + if ($haystack -match '(?i)(login|登录|统一身份|cas|authserver|lyuapServer|教务)') { $signals.Add('login-page') } + if ($haystack -match '(?i)(课表|课程表|教学安排|我的课表)') { $signals.Add('timetable-copy') } + $errorText = '' + break + } catch { + $errorText = $_.Exception.Message + if ($_.Exception.Response -and $_.Exception.Response.RequestMessage) { + $finalUrl = [string]$_.Exception.Response.RequestMessage.RequestUri.AbsoluteUri + try { $statusCode = [int]$_.Exception.Response.StatusCode } catch { } + } + } + } + + [pscustomobject]@{ + host = [string]$candidate.host + prefix = [string]$candidate.prefix + requestedUrl = $requestedUrl + statusCode = $statusCode + finalUrl = $finalUrl + title = $title + contentLength = $contentLength + profile = $profile + signals = @($signals) + error = $errorText + } +} -ThrottleLimit $ThrottleLimit + +$byHost = @{} +foreach ($probe in $probes) { $byHost[$probe.host] = $probe } +$result = foreach ($record in $records) { + $probe = $byHost[[string]$record.host] + [pscustomobject]@{ + index = [int]$record.index + id = [string]$record.id + name = [string]$record.name + rootDomain = [string]$record.rootDomain + prefix = [string]$record.prefix + host = [string]$record.host + addresses = @($record.addresses) + requestedUrl = [string]$probe.requestedUrl + statusCode = [int]$probe.statusCode + finalUrl = [string]$probe.finalUrl + title = [string]$probe.title + contentLength = [int]$probe.contentLength + profile = [string]$probe.profile + signals = @($probe.signals) + error = [string]$probe.error + } +} + +$result = @($result | Sort-Object index, prefix) +$result | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutputPath -Encoding UTF8 +Write-Output ("Probed {0} non-wildcard hosts for {1} schools; {2} returned HTTP 2xx and {3} had login/platform signals." -f $probes.Count, @($result.index | Sort-Object -Unique).Count, @($probes | Where-Object { $_.statusCode -ge 200 -and $_.statusCode -lt 300 }).Count, @($probes | Where-Object { $_.signals.Count -gt 0 }).Count) diff --git a/analysis/ziyan-url-recovery-20260908/verified_urls.json b/analysis/ziyan-url-recovery-20260908/verified_urls.json new file mode 100644 index 0000000..8611e74 --- /dev/null +++ b/analysis/ziyan-url-recovery-20260908/verified_urls.json @@ -0,0 +1,120 @@ +[ + {"id":"wakeup_3fab48604a90b456","name":"安徽工业经济职业技术学院","url":"http://jw.ahiec.edu.cn:65534/eams/loginExt.action","profile":"eams","evidence":"http://www.ahiec.edu.cn/jwc/"}, + {"id":"wakeup_7f277a002013dfc2","name":"安徽警官职业学院","url":"http://jw.ahjgxy.edu.cn/login.aspx","profile":"topology","evidence":"http://jw.ahjgxy.edu.cn/login.aspx"}, + {"id":"wakeup_e2c77dd823aae3f1","name":"安徽三联学院","url":"https://jwxt.slu.edu.cn:4060/ahsljw/cas/login.action","profile":"kingosoft_new","evidence":"https://jiaowu.slu.edu.cn/"}, + {"id":"wakeup_41acb501c9d4ecea","name":"安徽水利水电职业技术学院","url":"https://ehall.ahsdxy.edu.cn/","profile":"topology","evidence":"https://ehall.ahsdxy.edu.cn/"}, + {"id":"wakeup_1de8e8d06fe2d636","name":"北京政法职业学院","url":"https://portal.bcpl.edu.cn/log/one/login.aspx","profile":"topology","evidence":"https://portal.bcpl.edu.cn/log/one/login.aspx"}, + {"id":"wakeup_155981147cbf149e","name":"赤峰工业职业技术学院","url":"https://portal.cfgy.cn/","profile":"topology","evidence":"https://portal.cfgy.cn/"}, + {"id":"wakeup_a5120eb1bbc3f4d4","name":"重庆电信职业学院","url":"https://jwgl.cqtc.edu.cn/","profile":"topology","evidence":"https://jwgl.cqtc.edu.cn/"}, + {"id":"wakeup_972789748425acab","name":"长沙环境保护职业技术学院","url":"http://jwxt.cshbzy.com:18080/","profile":"topology","evidence":"https://www.hbcollege.com.cn/p1/index.html"}, + {"id":"wakeup_0907a3cfcf685e64","name":"长沙卫生职业学院","url":"https://portal.cswszy.com/","profile":"topology","evidence":"https://portal.cswszy.com/"}, + {"id":"wakeup_f1e7bb470c60f502","name":"大理农林职业技术学院","url":"http://jw.dlafc.edu.cn/","profile":"topology","evidence":"https://www.dlafc.edu.cn/"}, + {"id":"wakeup_839b0306eadd4fe4","name":"大连医科大学中山学院","url":"https://jw.dmuzs.edu.cn/","profile":"topology","evidence":"https://jw.dmuzs.edu.cn/"}, + {"id":"wakeup_3d75f8adc763cee2","name":"鄂尔多斯职业学院","url":"http://portal.open.ordosvc.cn:8009/frontend/center_portal/home/index.html","profile":"topology","evidence":"https://www.ordosvc.cn/"}, + {"id":"wakeup_7366a5ef7ee17c9f","name":"甘肃警察学院","url":"https://jwxt.gsjcxy.edu.cn/admin/login","profile":"chaoxing","evidence":"https://www.gsjcxy.edu.cn/"}, + {"id":"wakeup_fd3558fff133fb18","name":"甘肃警察职业学院","url":"https://jwxt.gsjcxy.edu.cn/admin/login","profile":"chaoxing","evidence":"https://www.gsjcxy.edu.cn/"}, + {"id":"wakeup_49bb37b64a4073fd","name":"广东工商职业技术大学","url":"https://jw.gdbtu.edu.cn/","profile":"kingosoft_new","evidence":"https://www.gdbtu.edu.cn/"}, + {"id":"wakeup_55c4a629ee20d6b2","name":"广西培贤国际职业学院","url":"https://course.peixianedu.com/","profile":"topology","evidence":"https://www.peixianedu.cn/"}, + {"id":"wakeup_f3f1ed668e9c9190","name":"桂林信息工程职业学院","url":"https://igx.glcie.edu.cn:8000/","profile":"topology","evidence":"https://www.glcie.edu.cn/"}, + {"id":"wakeup_f0e2d0239991725c","name":"合肥共达职业技术学院","url":"http://jwweb.hfgdxy.cn:8082/jwweb/","profile":"kingosoft_new","evidence":"https://www.hfgdxy.cn/"}, + {"id":"wakeup_4cfb37021eb1a293","name":"河北旅游职业学院","url":"https://portal.hbly.edu.cn/","profile":"topology","evidence":"https://www.hbly.edu.cn/"}, + {"id":"wakeup_8d5d0f50a784e6bb","name":"河北女子职业技术学院","url":"https://uap.hebnzxy.com/","profile":"topology","evidence":"https://uap.hebnzxy.com/"}, + {"id":"wakeup_d51b5a65b3e68c98","name":"河北石油职业技术大学","url":"https://ehall.hebpu.edu.cn/","profile":"topology","evidence":"https://ehall.hebpu.edu.cn/"}, + {"id":"wakeup_3dd6492079426e45","name":"河北石油职业技术学院","url":"https://ehall.hebpu.edu.cn/","profile":"topology","evidence":"https://ehall.hebpu.edu.cn/"}, + {"id":"wakeup_2573a1b5fd82b492","name":"河北体育学院","url":"https://portal.hepec.edu.cn/","profile":"topology","evidence":"https://www.hepec.edu.cn/"}, + {"id":"wakeup_4787af74f0ad04a2","name":"河北中医药大学","url":"https://portal.hebcm.edu.cn/","profile":"topology","evidence":"https://portal.hebcm.edu.cn/"}, + {"id":"wakeup_31e31cf00f91330d","name":"河南司法警官职业学院","url":"https://portal.hnsfjy.edu.cn/","profile":"topology","evidence":"https://portal.hnsfjy.edu.cn/"}, + {"id":"wakeup_7ae88dd32c7fac8c","name":"黑龙江农业职业技术学院","url":"https://hnzy.jw.chaoxing.com/admin/login/","profile":"chaoxing","evidence":"http://jw.hljnzy.edu.cn/"}, + {"id":"wakeup_94d9d9370c6487f4","name":"衡阳科技职业学院","url":"https://hykjxy.cn/login/","profile":"topology","evidence":"https://www.hykjxy.cn/"}, + {"id":"wakeup_d6f21b403ff62bb6","name":"湖南大众传媒职业技术学院","url":"http://ehall.hnmmc.cn/new/index.html","profile":"topology","evidence":"https://www.hnmmc.cn/"}, + {"id":"wakeup_9563fa7b50d3e6d8","name":"惠州工程职业学院","url":"https://ehall.hzevc.edu.cn/","profile":"topology","evidence":"https://ehall.hzevc.edu.cn/"}, + {"id":"wakeup_2d0012121ed9ff48","name":"江西传媒职业学院","url":"https://portal.jxcmzyxy.edu.cn/","profile":"topology","evidence":"https://www.jxcmzyxy.edu.cn/"}, + {"id":"wakeup_97f7aa3d9f215d2c","name":"江西陶瓷工艺美术职业技术学院","url":"http://zfjw.jxgymy.com:86/sso/zfiotlogin","profile":"zhengfang","authenticationUrls":["http://zfca.jxgymy.com:8080/cas/"],"evidence":"https://jwc.jacti.edu.cn/szhxy"}, + {"id":"wakeup_6a3cdee187dabdcc","name":"江西冶金职业技术学院","url":"https://zhxy.jxyjxy.com:8078/","profile":"topology","evidence":"https://www.jxyjxy.com/"}, + {"id":"wakeup_7a517e526cbb4ce8","name":"揭阳职业技术学院","url":"http://portal.jyc.edu.cn/","profile":"topology","evidence":"https://www.jyc.edu.cn/"}, + {"id":"wakeup_48ad4d2d03221acb","name":"荆州理工职业学院","url":"https://ehall.jzlg.edu.cn/index.html","profile":"eams","evidence":"https://www.jzlg.edu.cn/"}, + {"id":"wakeup_130743487c2f214b","name":"辽宁工程职业学院","url":"https://lngczyxy.jw.chaoxing.com/admin/login","profile":"chaoxing","evidence":"https://www.lngczyxy.com/"}, + {"id":"wakeup_e9d93e416b23eac5","name":"辽宁广告职业学院","url":"https://jwxt.ggxy.com/","profile":"topology","evidence":"https://jwxt.ggxy.com/"}, + {"id":"wakeup_bda78bd9eb0bfda6","name":"娄底职业技术学院","url":"http://jwgl.ldzy.edu.cn/admin/login","profile":"chaoxing","evidence":"https://www.ldzy.edu.cn/"}, + {"id":"wakeup_f00fad157efe473b","name":"罗定职业技术学院","url":"https://ehall.ldpoly.edu.cn/","profile":"topology","evidence":"https://ehall.ldpoly.edu.cn/"}, + {"id":"wakeup_4bd96729303f930c","name":"洛阳师范学院","url":"https://i.lynu.edu.cn/","profile":"topology","evidence":"https://i.lynu.edu.cn/"}, + {"id":"wakeup_49654df372dfc18e","name":"洛阳职业技术学院","url":"https://portal.lypt.edu.cn/","profile":"topology","evidence":"https://www.lypt.edu.cn/"}, + {"id":"wakeup_c6e6ca06a64e7cf8","name":"民办合肥经济技术职业学院","url":"https://hfjjxy.jw.chaoxing.com/admin/login","profile":"chaoxing","evidence":"https://jwc.hfjjxy.edu.cn/"}, + {"id":"wakeup_0e738c00509d49ff","name":"闽南理工学院","url":"https://zfjw.mnust.edu.cn:30443/jwglxt/xtgl/login_slogin.html","profile":"zhengfang","evidence":"https://jwc.mnust.edu.cn/"}, + {"id":"wakeup_7b180ce22a109959","name":"宁德职业技术学院","url":"https://jwxt.ndzy.cn:8310/","profile":"topology","evidence":"https://www.ndzy.cn/"}, + {"id":"wakeup_56386d6c178e5ead","name":"青海理工学院","url":"https://jw.qhit.edu.cn/","profile":"topology","evidence":"https://jw.qhit.edu.cn/"}, + {"id":"wakeup_9fd91d6c8b16bc1c","name":"曲靖职业技术学院","url":"https://i.qjzyxy.com/","profile":"topology","evidence":"https://www.qjzyxy.com/"}, + {"id":"wakeup_797aec176a54a695","name":"泉州工程职业技术学院","url":"http://jwxt.qzgcu.com/","profile":"qiangzhi","evidence":"https://www.qzgcu.com/"}, + {"id":"wakeup_e3e1d63e2ef529c1","name":"泉州工艺美术职业学院","url":"http://jwxt.qzgymy.cn/admin/login","profile":"chaoxing","evidence":"https://www.qzgymy.cn/"}, + {"id":"wakeup_9424f09329294b26","name":"山西财贸职业技术学院","url":"http://jw.sxcmvc.com/","profile":"topology","evidence":"http://www.sxcmvc.com/gljg/xzjg/jwc/bmgk.htm"}, + {"id":"wakeup_33fb98b9691060c9","name":"山西水利职业技术学院","url":"http://jwgl.sxsy.com.cn/","profile":"kingosoft_new","evidence":"https://www.sxsy.com.cn/"}, + {"id":"wakeup_7815ea33b4effc17","name":"陕西农林职业技术大学","url":"https://jwxt.ylvtc.com/jwglxt/xtgl/login_slogin.html","profile":"zhengfang","evidence":"https://www.ylvtc.com/"}, + {"id":"wakeup_7192af027b124be6","name":"上海农林职业技术学院","url":"https://jw.shafc.edu.cn/","profile":"topology","evidence":"https://jw.shafc.edu.cn/"}, + {"id":"wakeup_0332cfadd3c7a511","name":"韶关市职工大学","url":"https://portal.sggaoji.com/","profile":"topology","evidence":"https://portal.sggaoji.com/"}, + {"id":"wakeup_00ae6f969bc8fc2c","name":"四川开放大学","url":"https://idm.scou.cn/api/fusionportal/ssoLogin","profile":"topology","evidence":"https://www.scou.cn/"}, + {"id":"wakeup_9fc646b6a68393f9","name":"苏州城市学院","url":"https://jw.szcu.edu.cn/sso/ddlogin","profile":"topology","evidence":"https://jwc.szcu.edu.cn/"}, + {"id":"wakeup_61d41e74fe3b5fda","name":"遂宁职业学院","url":"http://jw.suiningc.com/eams/loginExt.action","profile":"eams","evidence":"http://jw.suiningc.com/eams/loginExt.action"}, + {"id":"wakeup_b6fc385fe4d5b302","name":"天津美术学院","url":"http://ehall.tjarts.edu.cn/new/index.html","profile":"topology","authenticationUrls":["http://ids.tjarts.edu.cn/authserver/"],"evidence":"https://www.tjarts.edu.cn/"}, + {"id":"wakeup_2baffe8fb3cd9289","name":"铜仁幼儿师范高等专科学校","url":"http://zhxy.trpec.edu.cn/","profile":"topology","evidence":"https://www.trpec.edu.cn/"}, + {"id":"wakeup_5658d1eaf8858bed","name":"温州职业技术学院","url":"https://portal.wzvtc.cn/dspaas","profile":"topology","evidence":"https://id.wzvtc.cn/cas/help/integrationSystem.html"}, + {"id":"wakeup_c9d42cde4c2fd426","name":"芜湖职业技术大学","url":"https://jwxt.whit.edu.cn/jwgl/xlogin.jsp","profile":"topology","evidence":"https://jwc.whit.edu.cn/"}, + {"id":"wakeup_0126883d07131937","name":"厦门安防科技职业学院","url":"https://jw.xmafkj.com:8081/","profile":"qiangzhi","evidence":"https://www.xmafkj.com/"}, + {"id":"wakeup_82a76a4d3c57f4fe","name":"厦门开放大学","url":"https://ehall.xmcu.edu.cn/ywtb-portal/standard/index.html","profile":"topology","evidence":"https://www.xmcu.edu.cn/"}, + {"id":"wakeup_af30ba83123c2def","name":"西安体育学院","url":"https://jwxt.xaipe.edu.cn/","profile":"topology","evidence":"https://www.xaipe.edu.cn/"}, + {"id":"wakeup_183a0c4055d2c74b","name":"西安铁路职业技术学院","url":"https://portal.xatzy.cn/","profile":"topology","evidence":"https://www.xatzy.edu.cn/"}, + {"id":"wakeup_82d55155bc2fb131","name":"西交利物浦大学","url":"https://ehall.xjtlu.edu.cn/login","profile":"topology","evidence":"https://www.xjtlu.edu.cn/"}, + {"id":"wakeup_2dc2620396d294e8","name":"新疆建设职业技术学院","url":"https://portal.xjjsxy.net/","profile":"topology","evidence":"https://portal.xjjsxy.net/"}, + {"id":"wakeup_645c895ba9f7f64a","name":"新疆生产建设兵团兴新职业技术学院","url":"https://jiaowu.btc.edu.cn/portal/","profile":"topology","evidence":"https://jiaowu.btc.edu.cn/"}, + {"id":"wakeup_a6e572de8efb6b15","name":"烟台幼儿师范高等专科学校","url":"https://ehall.ytpec.edu.cn/","profile":"topology","evidence":"https://ehall.ytpec.edu.cn/"}, + {"id":"wakeup_134fbc2f329dcfc4","name":"盐城幼儿师范高等专科学校","url":"http://ehall.yyz.edu.cn/","profile":"topology","evidence":"https://www.yyz.edu.cn/"}, + {"id":"wakeup_931d71da3d091650","name":"宜宾工业职业技术学院","url":"https://portal.ybipc.edu.cn/","profile":"topology","evidence":"https://portal.ybipc.edu.cn/"}, + {"id":"wakeup_669ccac94e51741c","name":"宜宾医药健康职业学院","url":"https://portal.ybmh.edu.cn/","profile":"topology","evidence":"https://portal.ybmh.edu.cn/"}, + {"id":"wakeup_70a764ee05f7b263","name":"永城职业学院","url":"http://my.ycvc.edu.cn/","profile":"topology","evidence":"https://www.ycvc.edu.cn/"}, + {"id":"wakeup_3638916c01b61d35","name":"云南科技信息职业学院","url":"https://i.ynkexin.cn/","profile":"topology","evidence":"https://i.ynkexin.cn/"}, + {"id":"wakeup_a33935e4d86bb27a","name":"漳州科技职业学院","url":"https://jwweb.tfzzkj.cn/zzkjjw/","profile":"kingosoft_new","evidence":"https://www.tftc.edu.cn/"}, + {"id":"wakeup_f52b90a5e776611e","name":"浙江特殊教育职业学院","url":"http://jw.zcse.edu.cn/","profile":"topology","evidence":"https://www.zcse.edu.cn/jyjx1/jxgl.htm"}, + {"id":"wakeup_df9e6e74af5ddec4","name":"中国社会科学院大学","url":"https://ehall.ucass.edu.cn/","profile":"topology","evidence":"https://www.ucass.edu.cn/"}, + {"id":"wakeup_fb32ed3417d6d217","name":"资阳环境科技职业学院","url":"https://portal.zycest.edu.cn/onlineServiceClient/","profile":"topology","evidence":"https://www.zycest.edu.cn/"}, + {"id":"wakeup_de9b893e981b5558","name":"自贡职业技术学院","url":"https://www.zhxywpt.cn/web/web/web/index","profile":"topology","evidence":"https://www.zgzyjsxy.com/"}, + {"id":"wakeup_8804e8f3817f03db","name":"安徽粮食工程职业学院","url":"https://jwxt.ahly.edu.cn/jsxsd/","profile":"qiangzhi","evidence":"https://www.ahly.edu.cn/"}, + {"id":"wakeup_660c5fe5d0d1a358","name":"安徽职业技术大学","url":"http://ehall.uta.edu.cn/","profile":"topology","authenticationUrls":["http://authserver.uta.edu.cn/authserver/"],"evidence":"https://www.uta.edu.cn/"}, + {"id":"wakeup_50bd80c5bb7682fb","name":"毕节医学高等专科学校","url":"https://bjyxxx.jw.chaoxing.com/admin/login","profile":"chaoxing","evidence":"https://www.bijiemc.cn/"}, + {"id":"wakeup_b11c35a367f78039","name":"德阳农业科技职业学院","url":"http://edu.dynky.cn:8083/default2.aspx","profile":"zhengfang","evidence":"https://www.dynky.cn/"}, + {"id":"wakeup_14dc7af6a534629c","name":"德州学院","url":"https://service.dzu.edu.cn/","profile":"topology","evidence":"https://www.dzu.edu.cn/"}, + {"id":"wakeup_d43da0de7a31ea1a","name":"东北石油大学 - 研究生","url":"https://portal.nepu.edu.cn/","profile":"topology","evidence":"https://www.nepu.edu.cn/"}, + {"id":"wakeup_407862ca89bbef49","name":"福州墨尔本理工职业学院","url":"http://student.fmp.edu.cn/builds/","profile":"topology","evidence":"https://www.fmp.edu.cn/"}, + {"id":"wakeup_138b811013862ff1","name":"广州体育职业技术学院","url":"http://portal.gztzy.edu.cn:8090/","profile":"topology","authenticationUrls":["https://cas.gztzy.edu.cn:8091/lyuapServer/"],"evidence":"https://www.gztzy.edu.cn/"}, + {"id":"wakeup_4f10653802e02f08","name":"海南健康管理职业技术学院","url":"http://my.hainhmc.edu.cn/","profile":"topology","authenticationUrls":["http://cas.hainhmc.edu.cn/cas/"],"evidence":"https://www.hainhmc.edu.cn/"}, + {"id":"wakeup_f4ad300fffb65b47","name":"邯郸科技职业学院","url":"http://jw.hdkz.edu.cn:8103/","profile":"topology","evidence":"https://hdkz.edu.cn/"}, + {"id":"wakeup_7b4c65ae423dbf06","name":"湖北铁道运输职业学院","url":"http://zhxy.hcrt.edu.cn:88/wcsas/","profile":"topology","evidence":"https://www.hcrt.edu.cn/"}, + {"id":"wakeup_d118b6aa58b8f85f","name":"江西机电职业技术学院","url":"http://jwgl.jxjdxy.edu.cn:8889/","profile":"topology","evidence":"https://www.jxjdxy.edu.cn/"}, + {"id":"wakeup_aeeb6ab0e95061d8","name":"江西科技职业学院","url":"http://portal.jxkeda.com:8280/home.aspx","profile":"kingosoft_new","evidence":"http://www.jxkeda.com/"}, + {"id":"wakeup_b3c465f4a9c3b11b","name":"开封工程职业学院","url":"https://bg.kfevc.edu.cn:20000/","profile":"topology","evidence":"https://www.kfevc.edu.cn/"}, + {"id":"wakeup_319b6255a6f47840","name":"民办合肥滨湖职业技术学院","url":"https://vp.hfbhxy.edu.cn/","profile":"topology","evidence":"https://www.hfbhxy.edu.cn/"}, + {"id":"wakeup_2a6344cdb8fd1407","name":"民办四川天一学院","url":"http://jw.sctianyi.net:8088/","profile":"zhengfang","evidence":"https://www.sctianyi.net/"}, + {"id":"wakeup_b98ef526d4d73574","name":"内蒙古鸿德文理学院","url":"https://login.honder.com:55233/","profile":"topology","evidence":"https://www.honder.com/news/info21604.html"}, + {"id":"wakeup_0ef62cd4dd432c17","name":"平凉职业技术学院","url":"http://bsdt.plvtc.cn:22080/jsxsd/","profile":"qiangzhi","evidence":"https://www.plvtc.cn/"}, + {"id":"wakeup_38a94f8dc04fbaef","name":"三亚城市职业学院","url":"http://jw.sycsxy.cn:7688/jwglxt/xtgl/login_slogin.html","profile":"zhengfang","evidence":"https://www.sycsxy.cn/"}, + {"id":"wakeup_3ff69da3ea680880","name":"山西体育职业学院","url":"https://enjoya.sxptc.com:1443/login/qfid","profile":"topology","authenticationUrls":["https://enjoyd.sxptc.com:1443/realms/sxptc/"],"evidence":"https://www.sxptc.com/"}, + {"id":"wakeup_ee4899f487aab456","name":"山西中医药大学","url":"https://jwxt.sxtcm.edu.cn/sso/jziotlogin","profile":"zhengfang","evidence":"https://www.sxtcm.edu.cn/index.htm"}, + {"id":"wakeup_459a6aa12b80222a","name":"陕西科技大学镐京学院","url":"http://jwgl.sxhju.cn:8002/hjxyjw/cas/login.action","profile":"kingosoft_new","evidence":"https://www.sxhju.cn/index.htm"}, + {"id":"wakeup_3a1e7a61714c5841","name":"四平职业大学","url":"http://jwxt.spvu.edu.cn/spzy/Login.aspx","profile":"topology","evidence":"http://www.spvu.edu.cn/index.asp"}, + {"id":"wakeup_bea1917d2a403b4d","name":"铁岭师范高等专科学校","url":"http://jw.tlsz.com.cn:7100/","profile":"topology","authenticationUrls":["https://cas.tlsz.com.cn/cas/"],"evidence":"https://www.tlsz.com.cn/"}, + {"id":"wakeup_8b160bcee4965d72","name":"中国青年政治学院","url":"https://portal.zytx.org.cn/","profile":"topology","evidence":"https://www.cyu.edu.cn/"}, + {"id":"wakeup_24ddb15d86a47ce2","name":"北京戏曲艺术职业学院","url":"https://portal.bjxx.com.cn/","profile":"topology","authenticationUrls":["https://sso.bjxx.com.cn/"],"evidence":"https://www.bjxx.com.cn/"}, + {"id":"wakeup_99d0bafc26b81dbb","name":"湖南环境生物职业技术学院","url":"https://portal.hnebp.edu.cn/","profile":"topology","evidence":"https://www.hnebp.edu.cn/"}, + {"id":"wakeup_0f8105c8bca8c8d5","name":"德州工程职业学院","url":"http://jw.qmxy.com/jwglxt/xtgl/login_slogin.html","profile":"zhengfang","evidence":"https://dzgcxy.com/2025/0116/c23a2738/page.htm"}, + {"id":"wakeup_f105b3e4b11a920d","name":"川北幼儿师范高等专科学校","url":"https://ehall.cbyz.edu.cn:16016/","profile":"topology","evidence":"https://www.cbyz.edu.cn/"}, + {"id":"wakeup_6284e5c1ace4c29d","name":"沧州师范学院","url":"https://eip.caztc.edu.cn/EIP/nonlogin/homePage.htm","profile":"topology","evidence":"https://www.caztc.edu.cn/"}, + {"id":"wakeup_a368b3a244bb6c0d","name":"兰州科技职业学院","url":"https://portal.lk.scedu.tech/onlineServiceClient/","profile":"topology","evidence":"https://www.lzkjedu.com/"}, + {"id":"wakeup_47dc08f6c97c44f1","name":"聊城科技职业学院","url":"https://lckj.jw.chaoxing.com/admin/login","profile":"chaoxing","evidence":"https://www.lckjzyxy.edu.cn/index.html"}, + {"id":"wakeup_09ded0102697dbf0","name":"六安应用科技职业学院","url":"https://ahlajsxy.ehall.chaoxing.com/","profile":"topology","evidence":"https://lastc.edu.cn/index.html"}, + {"id":"wakeup_f68b4c9d92cb4005","name":"山西管理职业学院","url":"https://ehall.sxglzyxy.com.cn/sxgl","profile":"topology","evidence":"https://www.sxglzyxy.com.cn/"}, + {"id":"wakeup_52b22d6c8ba7fdb0","name":"南阳医学高等专科学校","url":"https://ywtb.nymc.edu.cn/","profile":"topology","evidence":"https://www.nymc.edu.cn/szxy.htm"}, + {"id":"wakeup_aba9076cab1c6baf","name":"山西经贸职业学院","url":"https://eportal.sxemc.edu.cn/","profile":"topology","evidence":"https://www.sxemc.edu.cn/"}, + {"id":"wakeup_12048acf57d5d448","name":"安徽体育运动职业技术学院","url":"https://xxmh.ahty.edu.cn:1443/","profile":"topology","evidence":"https://www.ahty.edu.cn/"}, + {"id":"wakeup_1887f40ab5c4a154","name":"河北艺术职业学院","url":"https://ywtb.hebart.edu.cn/","profile":"topology","authenticationUrls":["https://sfrz.hebart.edu.cn/authserver/"],"evidence":"https://www.hebart.com/"}, + {"id":"wakeup_963e910849c99298","name":"陇南师范学院","url":"https://ic.lntc.edu.cn/","profile":"topology","authenticationUrls":["https://id.lntc.edu.cn/"],"evidence":"https://www.lntc.edu.cn/"}, + {"id":"wakeup_b255e0c19fff1868","name":"辽宁铁道职业技术学院","url":"https://ifw.lntdxy.com/EIP/nonlogin/homePage.htm","profile":"topology","authenticationUrls":["https://cas.lntdxy.com/ZAS/"],"evidence":"https://lntdxy.com/"}, + {"id":"wakeup_31ada1d6dcd691b7","name":"怒江职业技术学院","url":"https://njzj.hall.chaoxing.com/","profile":"topology","authenticationUrls":["https://njzjcas.libsou.com/cas/"],"evidence":"https://www.njzyjsxy.cn/"} +] diff --git a/app/build.gradle b/app/build.gradle index b5b49a3..90181f8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -50,6 +50,10 @@ dependencies { implementation 'androidx.customview:customview:1.1.0' implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.viewpager2:viewpager2:1.0.0' + implementation 'androidx.dynamicanimation:dynamicanimation:1.1.0' + + // Rive mascot runtime + implementation 'app.rive:rive-android:9.13.10' // Room Database implementation 'androidx.room:room-runtime:2.6.0' diff --git a/app/src/androidTest/java/com/courseschedule/ui/DateHeaderViewTest.kt b/app/src/androidTest/java/com/courseschedule/ui/DateHeaderViewTest.kt new file mode 100644 index 0000000..e5df8d8 --- /dev/null +++ b/app/src/androidTest/java/com/courseschedule/ui/DateHeaderViewTest.kt @@ -0,0 +1,146 @@ +package com.courseschedule.ui + +import android.animation.ValueAnimator +import android.graphics.Bitmap +import android.view.ViewGroup +import androidx.core.view.doOnLayout +import androidx.core.view.drawToBitmap +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.courseschedule.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@RunWith(AndroidJUnit4::class) +class DateHeaderViewTest { + + @Test + fun onlyChangedDigitsMoveAndUnchangedPrefixIsPixelIdentical() { + assumeTrue(ValueAnimator.areAnimatorsEnabled()) + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + isolateHeader(scenario) + scenario.onActivity { activity -> + val header = activity.findViewById(R.id.dateHeader) + header.setDate("2026/10/12", "第10周 · 周一", animate = true) + assertSettled(header) + } + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + scenario.onActivity { activity -> + val header = activity.findViewById(R.id.dateHeader) + val row = rows(header).first() + val before = row.drawToBitmap() + val prefixWidth = row.motion.glyphs.single { it.slot == DateDigitMotion.Slot(4, 1) }.fromX.toInt() + header.setDate("2026/10/19", "第11周 · 周一", animate = true) + row.advance(0.5f) + val after = row.drawToBitmap() + assertTrue("Unchanged year/month must not move or fade", Bitmap.createBitmap( + before, 0, 0, prefixWidth, before.height + ).sameAs(Bitmap.createBitmap(after, 0, 0, prefixWidth, after.height))) + assertEquals(setOf(DateDigitMotion.Slot(4, 0)), + row.motion.glyphs.filter { it.fromY != it.toY }.map { it.slot }.toSet()) + assertEquals(setOf(DateDigitMotion.Slot(1, 0)), + rows(header)[1].motion.glyphs.filter { it.fromY != it.toY }.map { it.slot }.toSet()) + + val state = row.motion.glyphs to row.motion.progress + header.setDate("2026/10/19", "第11周 · 周一", animate = false) + assertEquals(state, row.motion.glyphs to row.motion.progress) + } + Thread.sleep(450L) + scenario.onActivity { activity -> + val header = activity.findViewById(R.id.dateHeader) + assertEquals("2026/10/19,第11周 · 周一", header.contentDescription) + assertSettled(header) + header.setDate("2026/12/28", "第18周 · 周一", animate = false) + assertSettled(header) + } + } + } + + @Test + fun rapidRetargetReverseAndDetachKeepTheLatestTextAndNeverBlank() { + assumeTrue(ValueAnimator.areAnimatorsEnabled()) + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + isolateHeader(scenario) + scenario.onActivity { activity -> + val header = activity.findViewById(R.id.dateHeader) + header.setDate("2026/10/12", "第5周 · 周一", animate = false) + header.setDate("2026/10/19", "第6周 · 周一", animate = true) + } + Thread.sleep(80L) + scenario.onActivity { activity -> + val header = activity.findViewById(R.id.dateHeader) + assertOpacity(header) + header.setDate("2026/10/26", "第7周 · 周一", animate = true) + assertOpacity(header) + assertEquals("2026/10/26", header.dateText) + } + Thread.sleep(60L) + scenario.onActivity { activity -> + val header = activity.findViewById(R.id.dateHeader) + header.setDate("2026/10/12", "第5周 · 周一", animate = true, forward = false) + assertOpacity(header) + } + Thread.sleep(450L) + scenario.onActivity { activity -> + val header = activity.findViewById(R.id.dateHeader) + assertEquals("2026/10/12", header.dateText) + assertEquals("第5周 · 周一", header.summaryText) + assertSettled(header) + header.setDate("2026/11/2", "第8周 · 周一", animate = true) + val parent = header.parent as ViewGroup + val index = parent.indexOfChild(header) + val params = header.layoutParams + parent.removeView(header) + assertSettled(header) + assertEquals("2026/11/2", header.dateText) + parent.addView(header, index, params) + } + } + } + + private fun isolateHeader(scenario: ActivityScenario) { + val laidOut = CountDownLatch(1) + scenario.onActivity { activity -> + // Keep Room/semester observers from replacing the test strings asynchronously. + val original = activity.findViewById(R.id.dateHeader) + val parent = original.parent as ViewGroup + val index = parent.indexOfChild(original) + val params = original.layoutParams + parent.removeView(original) + val header = DateHeaderView(activity).apply { + id = R.id.dateHeader + doOnLayout { laidOut.countDown() } + } + parent.addView(header, index, params) + } + assertTrue("Date header was not laid out", laidOut.await(5L, TimeUnit.SECONDS)) + } + + private fun rows(header: DateHeaderView) = (0..1).map { header.getChildAt(it) as DateHeaderView.DigitRow } + + private fun assertOpacity(header: DateHeaderView) { + rows(header).forEach { row -> + row.motion.glyphs.groupBy { it.slot }.values.forEach { glyphs -> + assertEquals(1f, glyphs.sumOf { it.alpha(row.motion.progress).toDouble() }.toFloat(), 0.001f) + } + } + } + + private fun assertSettled(header: DateHeaderView) { + rows(header).forEach { row -> + assertFalse(row.motion.isMoving) + assertEquals(row.text.toString(), row.motion.glyphs.joinToString("") { it.text }) + row.motion.glyphs.forEach { + assertEquals(0f, it.y(row.motion.progress), 0f) + assertEquals(1f, it.alpha(row.motion.progress), 0f) + } + } + } +} diff --git a/app/src/androidTest/java/com/courseschedule/ui/importdata/GenericImportUiTest.kt b/app/src/androidTest/java/com/courseschedule/ui/importdata/GenericImportUiTest.kt new file mode 100644 index 0000000..baa3dc1 --- /dev/null +++ b/app/src/androidTest/java/com/courseschedule/ui/importdata/GenericImportUiTest.kt @@ -0,0 +1,328 @@ +package com.courseschedule.ui.importdata + +import android.os.SystemClock +import android.security.NetworkSecurityPolicy +import android.view.View +import android.view.ViewGroup +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.TextView +import androidx.core.widget.doAfterTextChanged +import androidx.lifecycle.ViewModelProvider +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.* +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.assertion.ViewAssertions.doesNotExist +import androidx.test.espresso.matcher.ViewMatchers.* +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.courseschedule.R +import com.courseschedule.ui.settings.SettingsActivity +import com.courseschedule.viewmodel.CourseViewModel +import com.google.gson.JsonParser +import org.hamcrest.Matchers.allOf +import org.json.JSONObject +import org.json.JSONTokener +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import java.io.ByteArrayInputStream +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +@RunWith(AndroidJUnit4::class) +class GenericImportUiTest { + @Test fun cleartextRedirectsDoNotUseADomainAllowlist() { + val policy = NetworkSecurityPolicy.getInstance() + assertTrue(policy.isCleartextTrafficPermitted("jw.sdufe.edu.cn")) + assertTrue(policy.isCleartextTrafficPermitted("example.com")) + assertTrue(policy.isCleartextTrafficPermitted("127.0.0.1")) + } + + @Suppress("DEPRECATION") + @Test fun academicWebViewAllowsCrossDomainWebLinksAndConfirmsSpecialSchemes() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val intent = AcademicWebImportActivity.schoolIntent(context, AcademicSchools.SWPU) + ActivityScenario.launch(intent).use { scenario -> + scenario.onActivity { activity -> + val web = activity.findViewById(R.id.webView) + web.stopLoading() + val client = web.webViewClient + assertFalse(client.shouldOverrideUrlLoading( + web, "https://login.vendor.example/sso" + )) + assertFalse(client.shouldOverrideUrlLoading( + web, "http://webvpn.vendor.example/login" + )) + assertTrue(client.shouldOverrideUrlLoading( + web, "vendor-sso://login/callback" + )) + } + onView(withText(R.string.academic_external_link_title)).check(matches(isDisplayed())) + } + } + + @Test fun settingsShowsPublicRepositoryAnnouncementAndClickableAddressCard() { + ActivityScenario.launch(SettingsActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + assertEquals("https://github.com/cakeni/CourseSchedule", + activity.findViewById(R.id.tvProjectUrl).text.toString()) + assertTrue(activity.findViewById(R.id.cardOpenSource).isClickable) + assertTrue(activity.findViewById(R.id.cardOpenSource).isLongClickable) + } + } + } + + @Test fun schoolImportOpensSearchableDirectoryInsteadOfAskingForAWebsite() { + ActivityScenario.launch(ImportActivity::class.java).use { scenario -> + val ready = CountDownLatch(1) + scenario.onActivity { activity -> + ViewModelProvider(activity)[CourseViewModel::class.java].currentSemester.observe(activity) { + if (it != null) ready.countDown() + } + } + assertTrue(ready.await(5, TimeUnit.SECONDS)) + onView(withId(R.id.cardImportSchool)).perform(click()) + onView(withId(R.id.etSchoolSearch)).check(matches(isDisplayed())) + .perform(replaceText("西南石油"), closeSoftKeyboard()) + onView(withText("西南石油大学")).check(matches(isDisplayed())) + onView(withId(R.id.etSchoolSearch)).perform(replaceText("xinan"), closeSoftKeyboard()) + onView(withText("西南石油大学")).check(matches(isDisplayed())) + onView(withId(R.id.btnRequestSchool)).check(matches(withText(R.string.academic_request_school))) + onView(withText(R.string.academic_generic_url_hint)).check(doesNotExist()) + } + } + + @Test fun bundledDirectoryKeepsFullCoverageButOnlyOpensSafeReviewedOrigins() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val entries = AcademicSchoolDirectory.entries(context) + val raw = context.assets.open("academic_school_directory.json").bufferedReader().use { + JsonParser.parseReader(it).asJsonObject.getAsJsonArray("entries") + } + val rejected = raw.mapNotNull { element -> + val item = element.asJsonObject + if (item.get("support")?.asString == "adapter_required") return@mapNotNull null + val profile = GenericAcademicImport.profile(item.get("profile")?.asString) ?: return@mapNotNull null + runCatching { + GenericAcademicImport.createCatalog(profile, item.get("url").asString, + item.get("cleartext")?.asBoolean == true, + item.getAsJsonArray("authenticationUrls")?.map { it.asString }.orEmpty()) + }.exceptionOrNull()?.let { "${item.get("name").asString}: ${it.message}" } + } + assertTrue("bundled rows rejected at runtime: ${rejected.joinToString(" | ")}", rejected.isEmpty()) + assertEquals(raw.size(), entries.size) + assertTrue("directory size=${entries.size}", entries.size >= 3_500) + assertEquals("西南石油大学", entries.first().name) + assertTrue(entries.first().verified) + assertTrue(entries.any { it.name == "浙江大学" }) + assertTrue(entries.any { it.name == "北京大学" }) + // The built-in NUAA graduate route replaces its adapter-required catalog row. + assertEquals(2_682, entries.count { it.canImport }) + assertEquals(884, entries.count { !it.canImport }) + assertEquals(764, entries.count { it.needsUserUrl }) + assertEquals(120, entries.count { !it.canImport && !it.needsUserUrl }) + assertEquals(1_088, entries.count { it.isCloudOnly }) + // The built-in SDUFE route upgrades the catalog's HTTP entry to WebVPN HTTPS. + assertEquals(934, entries.count { it.allowCleartext }) + entries.filter { it.canImport }.forEach { entry -> + val school = entry.toAcademicSchool() + assertNotNull(entry.name, school) + assertTrue(entry.name, school!!.loginUrl.startsWith("https://") || + entry.allowCleartext && school.loginUrl.startsWith("http://")) + assertTrue(entry.name, school.allowsNavigation(school.loginUrl)) + } + entries.filterNot { it.canImport }.forEach { assertNull(it.name, it.toAcademicSchool()) } + val zju = entries.first { it.name == "浙江大学" } + assertEquals("zdbk.zju.edu.cn", zju.host) + assertTrue(zju.url.startsWith("https://")) + val nuaa = entries.first { it.name == "南京航空航天大学" } + assertEquals("nuaa", nuaa.builtInSchoolId) + assertEquals("aao-eas.nuaa.edu.cn", nuaa.host) + assertTrue(nuaa.canImport) + val nuaaGraduate = entries.first { it.name == "南京航空航天大学 - 研究生" } + assertEquals("nuaa_graduate", nuaaGraduate.builtInSchoolId) + assertEquals("graduate.nuaa.edu.cn", nuaaGraduate.host) + assertTrue(nuaaGraduate.canImport) + } + + @Test fun genericSystemRowsAskForTheSchoolUrlInsteadOfClaimingTheyNeedAnAdapter() { + AcademicSchoolDirectory.entries(InstrumentationRegistry.getInstrumentation().targetContext) + ActivityScenario.launch(SchoolPickerActivity::class.java).use { scenario -> + val loaded = CountDownLatch(1) + scenario.onActivity { activity -> + val count = activity.findViewById(R.id.tvSchoolCount) + val loading = activity.getString(R.string.academic_school_loading) + if (count.text.toString() != loading) loaded.countDown() + count.doAfterTextChanged { if (it.toString() != loading) loaded.countDown() } + } + assertTrue("school directory did not finish loading", loaded.await(10, TimeUnit.SECONDS)) + onView(withId(R.id.chipCommonSystem)).perform(click()) + onView(withId(R.id.etSchoolSearch)).perform(replaceText("强智教务"), closeSoftKeyboard()) + onView(allOf(withId(R.id.tvSchoolName), withText("强智教务"))).perform(click()) + onView(withId(R.id.etGenericUrl)).check(matches(isDisplayed())) + onView(withText(R.string.academic_trust_and_open)).check(matches(isDisplayed())) + onView(withText(R.string.academic_adapter_required_title)).check(doesNotExist()) + } + } + + @Test fun genericHtmlCaptureCopiesOnlyScheduleMarkupAndParsesTheSnapshot() { + val school = GenericAcademicImport.create("urp", "https://jw.example.edu.cn/student/table") + withFixtureWebView(school) { web -> + val fixture = """ +
PRIVATE_OUTSIDE_TABLE
+ + +
课程名称星期节次周次教师教室
测试课程星期二3-4节1-2,5-6周测试教师测试教室
+ """.trimIndent() + evaluate(web, "document.body.innerHTML = " + JSONObject.quote(fixture)) + val payload = JSONObject(evaluate(web, AcademicCaptureScript.create(school)) as String) + assertFalse(payload.has("error")) + assertFalse(payload.toString().contains("PRIVATE_")) + assertFalse(payload.getString("html").contains(" + assertEquals("undefined", evaluate(web, "typeof window.CourseScheduleBridge")) + evaluate(web, MOCK_FETCH) + val token = "fixture-request" + val basePath = GenericAcademicImport.wiseduBasePath(school.loginUrl) + evaluate(web, AcademicWebImportActivity.ScriptHolder.fetchScript(token, "", basePath)) + val result = awaitResult(web, token) + assertFalse(result.has("error")) + val parsed = AcademicSchools.parse(school, result.getString("json"), 20) + assertEquals("测试课程", parsed.courses.first().courseName) + assertEquals(listOf(1 to 2, 4 to 5), parsed.courses.map { it.startWeek to it.endWeek }) + val paths = evaluate(web, "JSON.stringify(window.testRequestedPaths)").toString() + assertTrue(paths.contains("/http/course-upstream/jwapp/sys/wdkb/modules/xskcb/xskcb.do")) + assertFalse(result.toString().contains("PRIVATE_PASSWORD")) + assertEquals("undefined", evaluate(web, "typeof window['__courseSchedule_fixture-request']")) + assertEquals("{}", evaluate(web, AcademicWebImportActivity.ScriptHolder.pollResultScript(token))) + } + } + + @Test fun genericActivityDoesNotRegisterANativeBridgeOrRetainPastedTickets() { + val school = GenericAcademicImport.create(AcademicSystem.WISEDU, + "https://jw.fixture.example/jwapp/?ticket=PRIVATE_TICKET#PRIVATE_FRAGMENT") + val intent = AcademicWebImportActivity.schoolIntent( + InstrumentationRegistry.getInstrumentation().targetContext, school) + assertFalse(intent.toUri(0).contains("PRIVATE_")) + ActivityScenario.launch(intent).use { scenario -> + lateinit var web: WebView + scenario.onActivity { activity -> + web = activity.findViewById(R.id.webView) + web.stopLoading() + } + assertEquals("undefined", evaluate(web, "typeof window.CourseScheduleBridge")) + } + } + + @Test fun intentRoundTripPreservesAdapterAndThreeIndependentScopes() { + val original = GenericAcademicImport.createCatalog( + profile = GenericAcademicImport.profile("qiangzhi")!!, + address = "https://portal.example.edu.cn/login?ticket=PRIVATE", + allowCleartext = false, + authenticationUrls = listOf("https://auth.example.edu.cn/authserver/"), + timetableUrls = listOf("https://jw.example.edu.cn/jsxsd/"), + adapterId = AcademicAdapterRegistry.QIANGZHI_2024 + ) + val intent = AcademicWebImportActivity.schoolIntent( + InstrumentationRegistry.getInstrumentation().targetContext, + original + ) + assertFalse(intent.toUri(0).contains("PRIVATE")) + val restored = AcademicWebImportActivity.schoolFromIntent(intent)!! + assertEquals(AcademicAdapterRegistry.QIANGZHI_2024, restored.adapterId) + assertEquals(original.loginPrefixes, restored.loginPrefixes) + assertEquals(original.authenticationPrefixes, restored.authenticationPrefixes) + assertEquals(original.timetablePrefixes, restored.timetablePrefixes) + assertTrue(restored.allowsNavigation("https://portal.example.edu.cn/login")) + assertTrue(restored.allowsNavigation("https://auth.example.edu.cn/authserver/login")) + assertFalse(restored.allowsTimetable("https://auth.example.edu.cn/authserver/login")) + assertTrue(restored.allowsTimetable("https://jw.example.edu.cn/jsxsd/xskb/list.do")) + } + + @Test fun existingWiseduScriptUsesPageLocalResultAndKeepsVerifiedPaths() { + withFixtureWebView(AcademicSchools.SWPU) { web -> + assertEquals("undefined", evaluate(web, "typeof window.CourseScheduleBridge")) + evaluate(web, MOCK_FETCH) + evaluate(web, AcademicWebImportActivity.ScriptHolder.fetchScript("legacy", "2026-2027-1")) + val result = awaitResult(web, "legacy") + assertFalse(result.has("error")) + assertEquals("测试课程", AcademicSchools.parse(AcademicSchools.SWPU, result.getString("json"), 20) + .courses.first().courseName) + val paths = evaluate(web, "JSON.stringify(window.testRequestedPaths)").toString() + assertTrue(paths.contains("/jwapp/sys/wdkb/modules/xskcb/xskcb.do")) + } + } + + private fun awaitResult(web: WebView, token: String): JSONObject = + waitForObject(web, AcademicWebImportActivity.ScriptHolder.pollResultScript(token)) + + private fun waitForObject(web: WebView, script: String): JSONObject { + val deadline = SystemClock.elapsedRealtime() + 6000 + while (SystemClock.elapsedRealtime() < deadline) { + val result = JSONObject(evaluate(web, script).toString()) + if (result.has("json") || result.has("error")) return result + Thread.sleep(75) + } + throw AssertionError("Synthetic Wisedu response did not finish") + } + + private fun evaluate(web: WebView, script: String): Any? { + val ready = CountDownLatch(1) + val result = AtomicReference() + InstrumentationRegistry.getInstrumentation().runOnMainSync { + web.evaluateJavascript(script) { result.set(it); ready.countDown() } + } + assertTrue(ready.await(5, TimeUnit.SECONDS)) + return JSONTokener(result.get()).nextValue() + } + + @android.annotation.SuppressLint("SetJavaScriptEnabled") + private fun withFixtureWebView(school: AcademicSchool, action: (WebView) -> Unit) { + ActivityScenario.launch(ImportActivity::class.java).use { scenario -> + lateinit var web: WebView + val loaded = CountDownLatch(1) + scenario.onActivity { activity -> + web = WebView(activity) + web.settings.javaScriptEnabled = true + activity.findViewById(android.R.id.content).addView(web, + ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + web.webViewClient = object : WebViewClient() { + override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse = + WebResourceResponse("text/html", "UTF-8", ByteArrayInputStream(ByteArray(0))) + override fun onPageFinished(view: WebView?, url: String?) { loaded.countDown() } + } + web.loadDataWithBaseURL(school.loginUrl, + "", "text/html", "UTF-8", null) + } + try { + assertTrue(loaded.await(5, TimeUnit.SECONDS)) + action(web) + } finally { + scenario.onActivity { (web.parent as? ViewGroup)?.removeView(web); web.destroy() } + } + } + } + + private val MOCK_FETCH = """ + window.testRequestedPaths = []; + window.fetch = function(url, options) { + window.testRequestedPaths.push(String(url)); + const data = String(url).indexOf('dqxnxq.do') >= 0 ? {term:'2026-2027-1'} : + {rows:[{KCM:'测试课程',SKJS:'教师',JASMC:'教室',SKXQ:'2',KSJC:'3',JSJC:'4',SKZC:'110110'}]}; + return Promise.resolve({ok:true,status:200,text:function(){return Promise.resolve(JSON.stringify(data));}}); + }; + """.trimIndent() +} diff --git a/app/src/androidTest/java/com/courseschedule/ui/importdata/QiangzhiWebCaptureTest.kt b/app/src/androidTest/java/com/courseschedule/ui/importdata/QiangzhiWebCaptureTest.kt new file mode 100644 index 0000000..e194dba --- /dev/null +++ b/app/src/androidTest/java/com/courseschedule/ui/importdata/QiangzhiWebCaptureTest.kt @@ -0,0 +1,133 @@ +package com.courseschedule.ui.importdata + +import android.annotation.SuppressLint +import android.view.ViewGroup +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.json.JSONObject +import org.json.JSONTokener +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import java.io.ByteArrayInputStream +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +@RunWith(AndroidJUnit4::class) +class QiangzhiWebCaptureTest { + private val school = AcademicSchools.SDUFE + private val fixture = """ + +
PRIVATE_OUTSIDE_TABLE
+ + +
节次星期一星期二
第1-2节
测试课程
+ 测试教师
1-2,5-6(周)[01-02节]
+ 测试教室
+ + """.trimIndent() + + @Test fun capturesOnlyTimetableAndSelectedTermWithoutNativeBridgeOrCredentials() { + val result = capture(school.timetableUrl + "fixture", fixture) + assertFalse(result.has("error")) + assertEquals("2026-2027-1", result.getString("term")) + assertFalse(result.toString().contains("PRIVATE_")) + assertFalse(result.getString("html").contains("" + val result = capture("https://webvpn.sdufe.edu.cn/", wrapper, + mapOf(trusted to fixture, untrusted to fixture.replace("测试课程", "不可信课程"))) + assertFalse(result.has("error")) + assertEquals(trusted, result.getString("sourceUrl")) + assertTrue(result.getString("html").contains("测试课程")) + assertFalse(result.getString("html").contains("不可信课程")) + } + + @Test fun neverTreatsAnInputAsTheSemesterSelector() { + val html = fixture.replace( + "", + "" + ) + val result = capture(school.timetableUrl + "fixture", html) + assertFalse(result.has("error")) + assertEquals("", result.getString("term")) + assertFalse(result.toString().contains("2026-2027-1")) + } + + @Test fun refusesIdentityPagesAndReportsInaccessibleFrames() { + assertEquals("table", capture(school.loginUrl, fixture).getString("error")) + val foreign = "https://untrusted.example/fixture" + val result = capture(school.timetableUrl, + "", mapOf(foreign to fixture)) + assertEquals("frame", result.getString("error")) + } + + @Test fun genericWebVpnSnapshotNeverReadsAnApprovedAuthenticationResource() { + val generic = GenericAcademicImport.create(AcademicSystem.QIANGZHI_HTML, + "https://webvpn.example.edu.cn/http/course-upstream/table") + val auth = "https://webvpn.example.edu.cn/https/auth-upstream/login" + val allowed = GenericAcademicImport.allowAuthentication(generic, auth) + assertEquals("table", capture(auth, fixture, selectedSchool = allowed).getString("error")) + assertFalse(capture(generic.loginUrl, fixture, selectedSchool = allowed).has("error")) + } + + @Test fun oversizedTrustedDomIsRejectedBeforeNativeParsing() { + val repeated = (1..120).joinToString("") { "${"x".repeat(4096)}" } + val oversized = fixture.replace("", + "$repeated") + assertEquals("large", capture(school.timetableUrl + "fixture", oversized).getString("error")) + } + + @SuppressLint("SetJavaScriptEnabled") + private fun capture(url: String, html: String, frames: Map = emptyMap(), + selectedSchool: AcademicSchool = school): JSONObject { + val ready = CountDownLatch(1) + val captured = AtomicReference() + ActivityScenario.launch(ImportActivity::class.java).use { scenario -> + var testView: WebView? = null + scenario.onActivity { activity -> + val webView = WebView(activity) + testView = webView + webView.settings.javaScriptEnabled = true + activity.findViewById(android.R.id.content).addView(webView, + ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + webView.webViewClient = object : WebViewClient() { + // Serve only synthetic HTML from memory. No test request reaches a real school. + override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse = + WebResourceResponse("text/html", "UTF-8", ByteArrayInputStream( + frames[request?.url.toString()].orEmpty().toByteArray(Charsets.UTF_8))) + + override fun onPageFinished(view: WebView, finishedUrl: String?) { + view.postVisualStateCallback(1L, object : WebView.VisualStateCallback() { + override fun onComplete(requestId: Long) { + view.evaluateJavascript(AcademicCaptureScript.create(selectedSchool)) { value -> + captured.set(value) + ready.countDown() + } + } + }) + } + } + webView.loadDataWithBaseURL(url, html, "text/html", "UTF-8", null) + } + try { + assertTrue("WebView did not finish the snapshot", ready.await(20, TimeUnit.SECONDS)) + return JSONObject(JSONTokener(captured.get()).nextValue() as String) + } finally { + scenario.onActivity { testView?.destroy() } + } + } + } +} diff --git a/app/src/androidTest/java/com/courseschedule/view/CourseTableViewInteractionTest.kt b/app/src/androidTest/java/com/courseschedule/view/CourseTableViewInteractionTest.kt new file mode 100644 index 0000000..34bbb6d --- /dev/null +++ b/app/src/androidTest/java/com/courseschedule/view/CourseTableViewInteractionTest.kt @@ -0,0 +1,113 @@ +package com.courseschedule.view + +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.courseschedule.data.entity.Course +import com.courseschedule.domain.ScheduleRules +import com.courseschedule.ui.importdata.ImportActivity +import com.courseschedule.utils.SchedulePreferences +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CourseTableViewInteractionTest { + + @Test fun overlapSelectsCurrentWeekThenNearestInactiveCourse() { + ActivityScenario.launch(ImportActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + val view = attachedTable(activity.findViewById(android.R.id.content)) + val far = course("较远非本周", 10) + val near = course("较近非本周", 7) + val past = course("已经结束", 3) + val current = course("本周", 5) + var selected: Course? = null + view.setCurrentWeek(5) + view.setOnCourseClickListener { course, _, _ -> selected = course } + + view.setCourses( + ScheduleRules.selectCoursesForWeek(listOf(current, near, far), 5, true) + ) + tap(view, day = 1, section = 1) + assertEquals(current, selected) + + selected = null + view.setCourses( + ScheduleRules.selectCoursesForWeek(listOf(far, near, past), 5, true) + ) + tap(view, day = 1, section = 1) + assertEquals(near, selected) + + selected = null + var emptySlot: Pair? = null + view.setOnEmptySlotClickListener { day, section -> emptySlot = day to section } + view.setCourses(ScheduleRules.selectCoursesForWeek(listOf(past), 5, true)) + tap(view, day = 1, section = 1) + assertNull(selected) + assertEquals(1 to 1, emptySlot) + } + } + } + + @Test fun blankSlotReportsItsDayAndSection() { + ActivityScenario.launch(ImportActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + val view = attachedTable(activity.findViewById(android.R.id.content)) + var selected: Pair? = null + view.setOnEmptySlotClickListener { day, section -> selected = day to section } + + tap(view, day = 4, section = 3) + + assertEquals(4 to 3, selected) + } + } + } + + private fun attachedTable(parent: ViewGroup): CourseTableView { + val density = parent.resources.displayMetrics.density + val height = (64f * density * 12).toInt() + return CourseTableView(parent.context).apply { + applyDisplaySettings(true, true, 64, SchedulePreferences.DEFAULT_SECTION_TIMES) + parent.addView(this, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height)) + val width = parent.width.takeIf { it > 0 } ?: parent.resources.displayMetrics.widthPixels + measure( + View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY) + ) + layout(0, 0, width, height) + } + } + + private fun tap(view: CourseTableView, day: Int, section: Int) { + val density = view.resources.displayMetrics.density + val timeColumnWidth = 48f * density + val dayWidth = (view.width - timeColumnWidth) / 7f + val x = timeColumnWidth + (day - 0.5f) * dayWidth + val y = (section - 0.5f) * 64f * density + val downTime = SystemClock.uptimeMillis() + listOf( + MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, x, y, 0), + MotionEvent.obtain(downTime, downTime + 10L, MotionEvent.ACTION_UP, x, y, 0) + ).forEach { event -> + try { + view.onTouchEvent(event) + } finally { + event.recycle() + } + } + } + + private fun course(name: String, week: Int) = Course( + courseName = name, + dayOfWeek = 1, + startSection = 1, + endSection = 1, + startWeek = week, + endWeek = week + ) +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2d31172..d6371f5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + @@ -13,8 +14,10 @@ android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:networkSecurityConfig="@xml/network_security_config" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" + android:usesCleartextTraffic="true" android:theme="@style/Theme.CourseSchedule" tools:targetApi="31"> @@ -41,6 +44,18 @@ android:label="@string/import_course" android:theme="@style/Theme.CourseSchedule" /> + + + + , + week: Int, + showInactiveCourses: Boolean + ): List = courses + .groupBy { Triple(it.dayOfWeek, it.startSection, it.endSection) } + .values + .mapNotNull { sameSlot -> + sameSlot.mapNotNull { course -> + val nextWeek = (maxOf(week, course.startWeek)..course.endWeek) + .firstOrNull { isCourseInWeek(course, it) } + if (nextWeek == null || (!showInactiveCourses && nextWeek != week)) { + null + } else { + course to nextWeek + } + }.minByOrNull { it.second }?.first + } + fun coursesOverlap(first: Course, second: Course): Boolean { if (first.semesterId != second.semesterId || first.dayOfWeek != second.dayOfWeek) { return false diff --git a/app/src/main/java/com/courseschedule/ui/CourseDuckActionView.kt b/app/src/main/java/com/courseschedule/ui/CourseDuckActionView.kt new file mode 100644 index 0000000..b03e5bc --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/CourseDuckActionView.kt @@ -0,0 +1,107 @@ +package com.courseschedule.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.view.MotionEvent +import android.view.ViewGroup +import android.view.animation.PathInterpolator +import android.widget.FrameLayout +import app.rive.runtime.kotlin.RiveAnimationView +import app.rive.runtime.kotlin.core.Alignment +import app.rive.runtime.kotlin.core.Fit +import com.courseschedule.R + +/** Toolbar action backed by one long-lived Rive renderer. */ +class CourseDuckActionView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + private val riveView = RiveAnimationView(context).apply { + layoutParams = centeredLayoutParams() + isClickable = false + isFocusable = false + } + private var pressed = false + + init { + isClickable = true + isFocusable = true + importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES + clipChildren = false + clipToPadding = false + addView(riveView) + runCatching { + riveView.setRiveResource( + R.raw.course_duck, + artboardName = ARTBOARD_NAME, + stateMachineName = STATE_MACHINE_NAME, + autoplay = true, + fit = Fit.CONTAIN, + alignment = Alignment.CENTER + ) + } + } + + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean = true + + override fun onTouchEvent(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + pressed = true + animate().cancel() + animate() + .scaleX(0.97f) + .scaleY(0.97f) + .setDuration(75L) + .setInterpolator(PRESS_INTERPOLATOR) + .start() + return true + } + + MotionEvent.ACTION_UP -> { + val inside = event.x in 0f..width.toFloat() && event.y in 0f..height.toFloat() + releasePress() + if (inside) performClick() + return true + } + + MotionEvent.ACTION_CANCEL -> { + releasePress() + return true + } + } + return super.onTouchEvent(event) + } + + override fun performClick(): Boolean { + return super.performClick() + } + + private fun releasePress() { + if (!pressed) return + pressed = false + animate().cancel() + animate() + .scaleX(1f) + .scaleY(1f) + .setDuration(150L) + .setInterpolator(RELEASE_INTERPOLATOR) + .start() + } + + private fun centeredLayoutParams() = LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + Gravity.CENTER + ) + + private companion object { + const val ARTBOARD_NAME = "Artboard" + const val STATE_MACHINE_NAME = "State Machine 1" + val PRESS_INTERPOLATOR = PathInterpolator(0.2f, 0f, 0f, 1f) + val RELEASE_INTERPOLATOR = PathInterpolator(0.2f, 0f, 0f, 1f) + } +} diff --git a/app/src/main/java/com/courseschedule/ui/DateDigitMotion.kt b/app/src/main/java/com/courseschedule/ui/DateDigitMotion.kt new file mode 100644 index 0000000..9871682 --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/DateDigitMotion.kt @@ -0,0 +1,114 @@ +package com.courseschedule.ui + +/** Glyph matching for the short date / week labels, independent of Android rendering. */ +internal class DateDigitMotion { + data class Slot(val run: Int, val place: Int) + + data class Glyph( + val slot: Slot, + val text: String, + val fromX: Float, + val toX: Float = fromX, + val fromY: Float = 0f, + val toY: Float = 0f, + val fromAlpha: Float = 1f, + val toAlpha: Float = 1f + ) { + val isDigit: Boolean get() = slot.place >= 0 + fun x(progress: Float) = fromX + (toX - fromX) * progress + fun y(progress: Float) = fromY + (toY - fromY) * progress + fun alpha(progress: Float) = fromAlpha + (toAlpha - fromAlpha) * progress + } + + var glyphs: List = emptyList() + private set + var progress = 1f + private set + var isMoving = false + private set + private var target: List = emptyList() + + fun setText(text: String, widths: FloatArray, animate: Boolean, distance: Float) { + require(widths.size == text.length) + val next = layout(text, widths) + if (!animate || glyphs.isEmpty()) { + target = next + finish() + return + } + + // Retarget from what is on screen, including any still-fading digits. + val visible = glyphs.filter { it.alpha(progress) > 0f }.map { + Glyph(it.slot, it.text, it.x(progress), fromY = it.y(progress), fromAlpha = it.alpha(progress)) + } + val remaining = visible.toMutableList() + val result = mutableListOf() + val nextBySlot = next.associateBy { it.slot } + for (glyph in next) { + val sameSlot = visible.filter { it.slot == glyph.slot } + val matching = sameSlot.filter { it.text == glyph.text }.maxByOrNull { it.fromAlpha } + if (matching != null) { + remaining.remove(matching) + result += matching.copy(toX = glyph.toX, toY = 0f, toAlpha = 1f) + } else { + val startX = sameSlot.maxByOrNull { it.fromAlpha }?.fromX ?: glyph.fromX + result += glyph.copy( + fromX = startX, + fromY = if (glyph.isDigit) -distance else 0f, + fromAlpha = if (glyph.isDigit) 0f else 1f + ) + } + } + for (glyph in remaining) { + // Punctuation and weekday words never roll or fade, even if the weekday changes. + if (glyph.isDigit) result += glyph.copy( + toX = nextBySlot[glyph.slot]?.toX ?: glyph.fromX, + toY = distance, + toAlpha = 0f + ) + } + target = next + glyphs = result + progress = 0f + isMoving = result.any { + it.fromX != it.toX || it.fromY != it.toY || it.fromAlpha != it.toAlpha + } + if (!isMoving) finish() + } + + fun advance(fraction: Float) { + progress = fraction.coerceIn(0f, 1f) + if (progress == 1f) finish() + } + + private fun finish() { + glyphs = target + progress = 1f + isMoving = false + } + + private fun layout(text: String, widths: FloatArray): List { + val result = mutableListOf() + var start = 0 + var run = 0 + var x = 0f + while (start < text.length) { + val digit = text[start] in '0'..'9' + var end = start + 1 + while (end < text.length && (text[end] in '0'..'9') == digit) end++ + if (digit) { + // Match units/tens within each field, not raw string indices across slashes. + for (index in start until end) { + result += Glyph(Slot(run, end - index - 1), text[index].toString(), x) + x += widths[index] + } + } else { + result += Glyph(Slot(run, -1), text.substring(start, end), x) + for (index in start until end) x += widths[index] + } + start = end + run++ + } + return result + } +} diff --git a/app/src/main/java/com/courseschedule/ui/DateHeaderView.kt b/app/src/main/java/com/courseschedule/ui/DateHeaderView.kt new file mode 100644 index 0000000..25d66c4 --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/DateHeaderView.kt @@ -0,0 +1,144 @@ +package com.courseschedule.ui + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Canvas +import android.text.TextUtils +import android.util.AttributeSet +import android.view.animation.PathInterpolator +import android.widget.LinearLayout +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.graphics.withSave +import androidx.core.view.ViewCompat +import com.courseschedule.R +import kotlin.math.roundToInt + +/** Only changed digits roll; both rows share one clock and keep the toolbar's text styles. */ +class DateHeaderView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + private val dateRow = DigitRow(context, R.style.CourseScheduleToolbarTitle) + private val summaryRow = DigitRow(context, R.style.CourseScheduleToolbarSubtitle) + private val interpolator = PathInterpolator(0.2f, 0f, 0.1f, 1f) + private var animator: ValueAnimator? = null + + val dateText: String get() = dateRow.text.toString() + val summaryText: String get() = summaryRow.text.toString() + + init { + orientation = VERTICAL + addView(dateRow, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) + addView(summaryRow, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) + importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES + accessibilityLiveRegion = ACCESSIBILITY_LIVE_REGION_POLITE + ViewCompat.setAccessibilityHeading(this, true) + } + + fun setDate(date: String, summary: String, animate: Boolean, forward: Boolean = true) { + // LiveData and the pager can report the same week in one frame. Do not replay it. + if (date == dateText && summary == summaryText) return + val shouldAnimate = animate && dateText.isNotEmpty() && isAttachedToWindow && + isLaidOut && isShown && ValueAnimator.areAnimatorsEnabled() + cancelAnimator() + contentDescription = "$date,$summary" + val distance = resources.displayMetrics.density * 12f * (if (forward) 1f else -1f) + dateRow.setDigits(date, shouldAnimate, distance) + summaryRow.setDigits(summary, shouldAnimate, distance * 0.6f) + if (!dateRow.motion.isMoving && !summaryRow.motion.isMoving) return + + animator = ValueAnimator.ofFloat(0f, 1f).apply { + duration = 340L + interpolator = this@DateHeaderView.interpolator + addUpdateListener { + val progress = it.animatedValue as Float + dateRow.advance(progress) + summaryRow.advance(progress) + } + addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + animator = null + settle() + } + }) + } + animator?.start() + } + + private fun cancelAnimator() { + animator?.apply { + removeAllListeners() + removeAllUpdateListeners() + cancel() + } + animator = null + } + + private fun settle() { + dateRow.advance(1f) + summaryRow.advance(1f) + } + + override fun onDetachedFromWindow() { + cancelAnimator() + settle() + super.onDetachedFromWindow() + } + + internal class DigitRow(context: Context, appearance: Int) : AppCompatTextView(context) { + val motion = DateDigitMotion() + + init { + setTextAppearance(appearance) + minLines = 1 + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + textDirection = TEXT_DIRECTION_LTR + importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO + } + + fun setDigits(value: String, animate: Boolean, distance: Float) { + // Measure once per text change, never during an animation frame. + val widths = FloatArray(value.length) + paint.getTextWidths(value, widths) + motion.setText(value, widths, animate, distance) + text = value + invalidate() + } + + fun advance(progress: Float) { + if (!motion.isMoving) return + motion.advance(progress) + invalidate() + } + + override fun onDraw(canvas: Canvas) { + // Preserve native ellipsis for unusually large fonts / narrow windows. + if ((layout?.getEllipsisCount(0) ?: 0) > 0) { + super.onDraw(canvas) + return + } + val oldColor = paint.color + paint.color = currentTextColor + val textAlpha = paint.alpha + canvas.withSave { + clipRect(compoundPaddingLeft, 0, width - compoundPaddingRight, height) + for (glyph in motion.glyphs) { + paint.alpha = (textAlpha * glyph.alpha(motion.progress)).roundToInt() + if (paint.alpha == 0) continue + drawText( + glyph.text, + compoundPaddingLeft + glyph.x(motion.progress), + baseline + glyph.y(motion.progress), + paint + ) + } + } + paint.color = oldColor + } + } +} diff --git a/app/src/main/java/com/courseschedule/ui/MainActivity.kt b/app/src/main/java/com/courseschedule/ui/MainActivity.kt index 4ed00ac..43d82dd 100644 --- a/app/src/main/java/com/courseschedule/ui/MainActivity.kt +++ b/app/src/main/java/com/courseschedule/ui/MainActivity.kt @@ -3,15 +3,21 @@ package com.courseschedule.ui import android.Manifest import android.content.Intent import android.content.pm.PackageManager +import android.graphics.Color +import android.graphics.RectF +import android.net.Uri import android.os.Build import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View +import android.view.ViewGroup +import android.view.animation.PathInterpolator import android.widget.TextView import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat +import androidx.core.view.doOnPreDraw import androidx.lifecycle.ViewModelProvider import androidx.viewpager2.widget.ViewPager2 import com.courseschedule.R @@ -25,12 +31,15 @@ import com.courseschedule.ui.settings.SettingsActivity import com.courseschedule.domain.SemesterPhase import com.courseschedule.domain.SemesterWeekStatus import com.courseschedule.utils.SchedulePreferences +import com.courseschedule.view.CourseTableView import com.courseschedule.viewmodel.CourseViewModel -import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date import java.util.Locale +import kotlin.math.abs /** * 主界面 - 课程表显示 @@ -46,15 +55,44 @@ class MainActivity : AppCompatActivity() { private var currentCourses: List = emptyList() private var semesterWeekStatus: SemesterWeekStatus? = null private var pageSettings = WeekPageSettings() + private var lastPagerPosition = -1 + private var lastAnimatedPagerPosition = -1 + private var pendingPagerMotionPosition = -1 + private var pendingPagerMotionForward = true + private var pagerMotionReady = false + private var semesterDataLoaded = false + private var coursesDataLoaded = false + private var suppressBottomNavigationMotion = false + private var hasResumedOnce = false + private var dateHeaderWeek: Int? = null + private var dateHeaderSemesterId: Long? = null + private val headerInterpolator = PathInterpolator(0.2f, 0.8f, 0.2f, 1f) private val pageChangeCallback = object : ViewPager2.OnPageChangeCallback() { override fun onPageSelected(position: Int) { + val previousPosition = lastPagerPosition + lastPagerPosition = position val week = position + 1 if (currentWeek != week) { currentWeek = week viewModel.setCurrentWeek(week) } updateWeekDisplay() + if (pagerMotionReady && previousPosition >= 0 && previousPosition != position) { + pendingPagerMotionPosition = position + pendingPagerMotionForward = position > previousPosition + animateWeekHeader(forward = pendingPagerMotionForward) + if (binding.weekPager.scrollState == ViewPager2.SCROLL_STATE_IDLE) { + playSelectedPageMotion(position, pendingPagerMotionForward) + pendingPagerMotionPosition = -1 + } + } + } + + override fun onPageScrollStateChanged(state: Int) { + if (state != ViewPager2.SCROLL_STATE_IDLE || pendingPagerMotionPosition < 0) return + playSelectedPageMotion(pendingPagerMotionPosition, pendingPagerMotionForward) + pendingPagerMotionPosition = -1 } } @@ -70,6 +108,13 @@ class MainActivity : AppCompatActivity() { // 设置工具栏 setSupportActionBar(binding.toolbar) + supportActionBar?.setDisplayShowTitleEnabled(false) + binding.tvProjectLink.installPressScale(pressedScale = 0.98f) + binding.tvProjectLink.setOnClickListener { + runCatching { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.project_repository_url)))) + } + } // 初始化ViewModel viewModel = ViewModelProvider(this)[CourseViewModel::class.java] @@ -85,11 +130,22 @@ class MainActivity : AppCompatActivity() { private fun initViews() { weekPagerAdapter = WeekPagerAdapter( onCourseClick = ::showCourseDetails, - onAddCourse = { startActivity(Intent(this, AddCourseActivity::class.java)) } + onAddCourse = { day, section -> openNewCourse(day, section) } ) binding.weekPager.adapter = weekPagerAdapter + binding.weekPager.visibility = View.INVISIBLE binding.weekPager.offscreenPageLimit = 1 binding.weekPager.registerOnPageChangeCallback(pageChangeCallback) + binding.weekPager.setPageTransformer { page, position -> + val distance = abs(position).coerceIn(0f, 1f) + val scale = 1f - (distance * 0.02f) + page.alpha = 1f - (distance * 0.16f) + page.scaleX = scale + page.scaleY = scale + page.translationX = -position * dp(14f) * (1f - distance) + page.rotationY = 0f + page.findViewById(R.id.courseTableView)?.setPagerOffset(position) + } binding.btnPreviousWeek.setOnClickListener { selectWeek(currentWeek - 1, smoothScroll = true) @@ -100,36 +156,88 @@ class MainActivity : AppCompatActivity() { } binding.weekInfo.setOnClickListener { showWeekPicker() } - - binding.fabAddCourse.setOnClickListener { - startActivity(Intent(this, AddCourseActivity::class.java)) - } + binding.weekInfo.installPressScale(0.97f) + binding.btnPreviousWeek.installPressScale(0.97f) + binding.btnNextWeek.installPressScale(0.97f) binding.bottomNavigation.setOnItemSelectedListener { item -> + if (suppressBottomNavigationMotion) return@setOnItemSelectedListener true + val itemView = binding.bottomNavigation.findViewById(item.itemId) when (item.itemId) { - R.id.nav_home -> true + R.id.nav_home -> { + itemView.playNavigationMotion() + true + } R.id.nav_import -> { - startActivity(Intent(this, ImportActivity::class.java)) + openTab(Intent(this, ImportActivity::class.java)) true } R.id.nav_settings -> { - startActivity(Intent(this, SettingsActivity::class.java)) + openTab(Intent(this, SettingsActivity::class.java)) true } else -> false } } + binding.bottomNavigation.setOnItemReselectedListener { item -> + binding.bottomNavigation.findViewById(item.itemId)?.playNavigationMotion() + } + } + + private fun animateWeekHeader(forward: Boolean) { + val offset = dp(if (forward) 18f else -18f) + listOf(binding.tvCurrentWeek, binding.tvWeekContext).forEachIndexed { index, view -> + view.animate().cancel() + view.alpha = 0.25f + view.translationX = offset + view.animate() + .alpha(1f) + .translationX(0f) + .setStartDelay(index * 28L) + .setDuration(290L) + .setInterpolator(headerInterpolator) + .start() + } + } + + private fun openTab(intent: Intent) { + startActivity(intent) + overridePendingTransition(0, 0) + } + + private fun playSelectedPageMotion(position: Int, forward: Boolean = true) { + if (lastAnimatedPagerPosition == position) return + binding.weekPager.post { + if (binding.weekPager.currentItem != position) return@post + if (weekPagerAdapter.playSelectionMotion(binding.weekPager, position, forward)) { + lastAnimatedPagerPosition = position + } + } } + private fun dp(value: Float): Float = value * resources.displayMetrics.density + private fun observeData() { viewModel.currentSemester.observe(this) { semester -> + semesterDataLoaded = true + if (currentSemester?.id != semester?.id) { + lastAnimatedPagerPosition = -1 + pendingPagerMotionPosition = -1 + pagerMotionReady = false + } currentSemester = semester semester?.let { - binding.toolbar.subtitle = it.name refreshWeekPager() syncPagerToCurrentWeek(smoothScroll = false) updateWeekDisplay() + binding.weekPager.post { + if (currentSemester?.id == it.id) { + lastPagerPosition = binding.weekPager.currentItem + pagerMotionReady = true + } + } } + showWeekPagerWhenReady() } viewModel.currentWeek.observe(this) { week -> @@ -145,9 +253,17 @@ class MainActivity : AppCompatActivity() { } viewModel.allCourses.observe(this) { courses -> + coursesDataLoaded = true currentCourses = courses refreshWeekPager() updateWeekDisplay() + showWeekPagerWhenReady() + } + } + + private fun showWeekPagerWhenReady() { + if (semesterDataLoaded && coursesDataLoaded) { + binding.weekPager.visibility = View.VISIBLE } } @@ -185,8 +301,9 @@ class MainActivity : AppCompatActivity() { } } - private fun showCourseDetails(course: Course) { + private fun showCourseDetails(course: Course, sourceView: View, sourceBounds: RectF) { val detailView = layoutInflater.inflate(R.layout.dialog_course_details, null) + detailView.findViewById(R.id.tvDetailTitle).text = course.courseName val teacherMissing = course.teacher.isBlank() detailView.findViewById(R.id.tvDetailTeacher).text = if (teacherMissing) { getString(R.string.teacher_not_provided) @@ -224,12 +341,80 @@ class MainActivity : AppCompatActivity() { noteRow.visibility = View.VISIBLE } - MaterialAlertDialogBuilder(this) - .setTitle(course.courseName) - .setView(detailView) - .setNegativeButton(R.string.close, null) - .setPositiveButton(R.string.edit) { _, _ -> openCourseEditor(course) } - .show() + val dialog = BottomSheetDialog(this) + detailView.findViewById(R.id.btnEditCourse).apply { + installPressScale(0.9f) + setOnClickListener { + dialog.dismiss() + openCourseEditor(course) + } + } + dialog.setContentView(detailView) + dialog.setOnShowListener { + dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet)?.apply { + setBackgroundColor(Color.TRANSPARENT) + playCourseDetailEntrance(detailView, sourceView, sourceBounds) + } + dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED + dialog.behavior.skipCollapsed = true + } + dialog.show() + } + + private fun playCourseDetailEntrance( + detailView: View, + sourceView: View, + sourceBounds: RectF + ) { + detailView.doOnPreDraw { + val sourceLocation = IntArray(2) + val detailLocation = IntArray(2) + sourceView.getLocationOnScreen(sourceLocation) + detailView.getLocationOnScreen(detailLocation) + + val sourceCenterX = sourceLocation[0] + sourceBounds.centerX() + val sourceCenterY = sourceLocation[1] + sourceBounds.centerY() + val detailCenterX = detailLocation[0] + detailView.width / 2f + val detailCenterY = detailLocation[1] + detailView.height / 2f + + detailView.pivotX = (sourceCenterX - detailLocation[0]) + .coerceIn(0f, detailView.width.toFloat()) + detailView.pivotY = (sourceCenterY - detailLocation[1]) + .coerceIn(0f, detailView.height.toFloat()) + detailView.alpha = 0.72f + detailView.scaleX = 0.9f + detailView.scaleY = 0.92f + detailView.translationX = ((sourceCenterX - detailCenterX) * 0.16f) + .coerceIn(-dp(44f), dp(44f)) + detailView.translationY = ((sourceCenterY - detailCenterY) * 0.18f) + .coerceIn(-dp(72f), dp(104f)) + + (detailView as? ViewGroup)?.let { content -> + repeat(content.childCount) { index -> + content.getChildAt(index).apply { + alpha = 0f + translationY = dp(10f + index.coerceAtMost(3) * 2f) + animate() + .alpha(1f) + .translationY(0f) + .setStartDelay(90L + index * 36L) + .setDuration(330L) + .setInterpolator(headerInterpolator) + .start() + } + } + } + + detailView.animate() + .alpha(1f) + .scaleX(1f) + .scaleY(1f) + .translationX(0f) + .translationY(0f) + .setDuration(480L) + .setInterpolator(headerInterpolator) + .start() + } } private fun openCourseEditor(course: Course) { @@ -240,12 +425,49 @@ class MainActivity : AppCompatActivity() { startActivity(intent) } + private fun openNewCourse(dayOfWeek: Int? = null, section: Int? = null) { + val intent = Intent(this, AddCourseActivity::class.java).apply { + dayOfWeek?.let { putExtra(AddCourseActivity.EXTRA_DAY_OF_WEEK, it) } + section?.let { putExtra(AddCourseActivity.EXTRA_SECTION, it) } + } + startActivity(intent) + } + /** * 更新周次显示 */ private fun updateWeekDisplay() { val semester = currentSemester ?: return val status = semesterWeekStatus + val displayDate = Calendar.getInstance().apply { + if (status?.phase != SemesterPhase.ACTIVE || status.week != currentWeek) { + timeInMillis = semester.startDate + add(Calendar.DAY_OF_MONTH, (currentWeek - 1) * 7) + } + } + val dateTitle = SimpleDateFormat("yyyy/M/d", Locale.CHINA).format(displayDate.time) + val weekday = resources.getStringArray(R.array.weekdays).getOrElse( + when (displayDate.get(Calendar.DAY_OF_WEEK)) { + Calendar.MONDAY -> 0 + Calendar.TUESDAY -> 1 + Calendar.WEDNESDAY -> 2 + Calendar.THURSDAY -> 3 + Calendar.FRIDAY -> 4 + Calendar.SATURDAY -> 5 + Calendar.SUNDAY -> 6 + else -> 0 + } + ) { "" } + val previousHeaderWeek = dateHeaderWeek + binding.dateHeader.setDate( + date = dateTitle, + summary = getString(R.string.toolbar_week_summary, currentWeek, weekday), + animate = pagerMotionReady && dateHeaderSemesterId == semester.id && + previousHeaderWeek != null && previousHeaderWeek != currentWeek, + forward = currentWeek >= (previousHeaderWeek ?: currentWeek) + ) + dateHeaderWeek = currentWeek + dateHeaderSemesterId = semester.id binding.tvCurrentWeek.text = getString(R.string.week_format, currentWeek) val isOutsideSemester = status?.phase == SemesterPhase.BEFORE || status?.phase == SemesterPhase.AFTER @@ -263,12 +485,6 @@ class MainActivity : AppCompatActivity() { weekCourses.filter { it.dayOfWeek <= 5 } } binding.tvWeekContext.text = buildWeekContext(status, visibleCourses.size) - binding.fabAddCourse.visibility = if (visibleCourses.isEmpty()) { - android.view.View.GONE - } else { - android.view.View.VISIBLE - } - binding.weekProgress.max = semester.totalWeeks binding.weekProgress.progress = currentWeek binding.weekProgress.setIndicatorColor( @@ -324,24 +540,47 @@ class MainActivity : AppCompatActivity() { override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.menu_main, menu) + menu.findItem(R.id.action_today)?.actionView?.setOnClickListener { + goToCurrentWeek() + } return true } override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { + R.id.action_add_course -> { + openNewCourse() + true + } R.id.action_today -> { - viewModel.refreshSemesterStatus() - semesterWeekStatus?.week?.let { selectWeek(it, smoothScroll = true) } + goToCurrentWeek() true } else -> super.onOptionsItemSelected(item) } } + private fun goToCurrentWeek() { + viewModel.refreshSemesterStatus() + semesterWeekStatus?.week?.let { selectWeek(it, smoothScroll = true) } + } + override fun onResume() { super.onResume() applyDisplaySettings() - binding.bottomNavigation.selectedItemId = R.id.nav_home + val returningToHome = hasResumedOnce && + binding.bottomNavigation.selectedItemId != R.id.nav_home + if (binding.bottomNavigation.selectedItemId != R.id.nav_home) { + suppressBottomNavigationMotion = true + binding.bottomNavigation.selectedItemId = R.id.nav_home + suppressBottomNavigationMotion = false + } + if (returningToHome) { + binding.bottomNavigation.post { + binding.bottomNavigation.findViewById(R.id.nav_home)?.playNavigationMotion() + } + } + hasResumedOnce = true refreshWeekPager() updateWeekDisplay() } @@ -351,6 +590,7 @@ class MainActivity : AppCompatActivity() { pageSettings = WeekPageSettings( showWeekend = prefs.showWeekend, showTimes = prefs.showTime, + showInactiveCourses = prefs.showInactiveCourses, sectionHeightDp = prefs.sectionHeightDp, sectionTimes = prefs.sectionTimes ) diff --git a/app/src/main/java/com/courseschedule/ui/ViewMotion.kt b/app/src/main/java/com/courseschedule/ui/ViewMotion.kt new file mode 100644 index 0000000..1515769 --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/ViewMotion.kt @@ -0,0 +1,136 @@ +package com.courseschedule.ui + +import android.view.MotionEvent +import android.view.View +import android.view.animation.OvershootInterpolator +import android.view.animation.PathInterpolator +import com.courseschedule.R +import com.google.android.material.R as MaterialR + +private val pressReleaseInterpolator = PathInterpolator(0.22f, 1f, 0.36f, 1f) + +fun View.installPressScale(pressedScale: Float = 0.98f) { + setOnTouchListener { target, event -> + val targetScale = when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> pressedScale + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> 1f + else -> return@setOnTouchListener false + } + target.animate().cancel() + target.animate() + .scaleX(targetScale) + .scaleY(targetScale) + .setDuration(if (targetScale < 1f) 115L else 245L) + .setInterpolator(pressReleaseInterpolator) + .start() + false + } +} + +fun View?.playNavigationMotion() { + val item = this + if (item == null) return + val icon = item.findViewById(MaterialR.id.navigation_bar_item_icon_view) ?: item + val density = item.resources.displayMetrics.density + icon.animate().cancel() + icon.apply { + scaleX = 1f + scaleY = 1f + translationY = 0f + rotation = 0f + rotationY = 0f + } + + when (item.id) { + R.id.nav_home -> icon.apply { + cameraDistance = 8_000f * density + animate() + .rotationY(-38f) + .scaleX(0.91f) + .scaleY(0.91f) + .setDuration(280L) + .setInterpolator(pressReleaseInterpolator) + .withLayer() + .withEndAction { + animate() + .rotationY(0f) + .scaleX(1f) + .scaleY(1f) + .setDuration(520L) + .setInterpolator(OvershootInterpolator(0.7f)) + .withLayer() + .start() + } + .start() + } + + R.id.nav_import -> { + icon.animate() + .translationY(4f * density) + .scaleX(0.9f) + .scaleY(0.9f) + .setDuration(220L) + .setInterpolator(pressReleaseInterpolator) + .withLayer() + .withEndAction { + icon.animate() + .translationY(-8f * density) + .scaleX(1.07f) + .scaleY(1.07f) + .setDuration(300L) + .setInterpolator(pressReleaseInterpolator) + .withLayer() + .withEndAction { + icon.animate() + .translationY(0f) + .scaleX(1f) + .scaleY(1f) + .setDuration(430L) + .setInterpolator(OvershootInterpolator(0.68f)) + .withLayer() + .start() + } + .start() + } + .start() + } + + R.id.nav_settings -> { + icon.animate() + .rotation(88f) + .scaleX(0.93f) + .scaleY(0.93f) + .setDuration(400L) + .setInterpolator(pressReleaseInterpolator) + .withLayer() + .withEndAction { + icon.animate() + .rotation(0f) + .scaleX(1f) + .scaleY(1f) + .setDuration(520L) + .setInterpolator(OvershootInterpolator(0.6f)) + .withLayer() + .start() + } + .start() + } + + else -> { + icon.animate() + .scaleX(0.9f) + .scaleY(0.9f) + .setDuration(260L) + .setInterpolator(pressReleaseInterpolator) + .withEndAction { + icon.animate() + .scaleX(1f) + .scaleY(1f) + .setDuration(520L) + .setInterpolator(OvershootInterpolator(0.7f)) + .start() + } + .start() + } + } +} diff --git a/app/src/main/java/com/courseschedule/ui/WeekPagerAdapter.kt b/app/src/main/java/com/courseschedule/ui/WeekPagerAdapter.kt index c671bb4..0e15fc6 100644 --- a/app/src/main/java/com/courseschedule/ui/WeekPagerAdapter.kt +++ b/app/src/main/java/com/courseschedule/ui/WeekPagerAdapter.kt @@ -1,12 +1,15 @@ package com.courseschedule.ui import android.graphics.Typeface +import android.graphics.RectF import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.animation.PathInterpolator import android.widget.TextView import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 import com.courseschedule.R import com.courseschedule.data.entity.Course import com.courseschedule.data.entity.Semester @@ -22,13 +25,14 @@ import java.util.Locale data class WeekPageSettings( val showWeekend: Boolean = true, val showTimes: Boolean = true, + val showInactiveCourses: Boolean = true, val sectionHeightDp: Int = 64, val sectionTimes: List = SchedulePreferences.DEFAULT_SECTION_TIMES ) class WeekPagerAdapter( - private val onCourseClick: (Course) -> Unit, - private val onAddCourse: () -> Unit + private val onCourseClick: (Course, View, RectF) -> Unit, + private val onAddCourse: (dayOfWeek: Int?, section: Int?) -> Unit ) : RecyclerView.Adapter() { private var semester: Semester? = null @@ -36,6 +40,7 @@ class WeekPagerAdapter( private var status: SemesterWeekStatus? = null private var settings = WeekPageSettings() private val scrollPositions = mutableMapOf() + private val settleInterpolator = PathInterpolator(0.2f, 0.85f, 0.25f, 1f) init { setHasStableIds(true) @@ -90,9 +95,19 @@ class WeekPagerAdapter( holder.boundWeek?.let { week -> scrollPositions[week] = holder.binding.scheduleScroll.scrollY } + holder.resetSelectionMotion() + holder.binding.courseTableView.resetPagerMotion() super.onViewRecycled(holder) } + fun playSelectionMotion(pager: ViewPager2, position: Int, forward: Boolean): Boolean { + val recyclerView = pager.getChildAt(0) as? RecyclerView ?: return false + val holder = recyclerView.findViewHolderForAdapterPosition(position) as? WeekViewHolder + ?: return false + holder.playSelectionMotion(forward) + return true + } + inner class WeekViewHolder( val binding: ItemWeekScheduleBinding ) : RecyclerView.ViewHolder(binding.root) { @@ -107,12 +122,20 @@ class WeekPagerAdapter( settings: WeekPageSettings ) { val previousWeek = boundWeek + if (previousWeek != week) { + resetSelectionMotion() + binding.courseTableView.resetPagerMotion() + } boundWeek = week - val pageCourses = courses.filter { ScheduleRules.isCourseInWeek(it, week) } + val displayCourses = ScheduleRules.selectCoursesForWeek( + courses, + week, + settings.showInactiveCourses + ) val visibleCourses = if (settings.showWeekend) { - pageCourses + displayCourses } else { - pageCourses.filter { it.dayOfWeek <= 5 } + displayCourses.filter { it.dayOfWeek <= 5 } } bindDayHeaders(week, semester, status, settings.showWeekend) @@ -123,10 +146,14 @@ class WeekPagerAdapter( sectionTimes = settings.sectionTimes ) binding.courseTableView.setCurrentWeek(week) - binding.courseTableView.setCourses(pageCourses) + binding.courseTableView.setCourses(displayCourses) binding.courseTableView.setOnCourseClickListener(onCourseClick) + binding.courseTableView.setOnEmptySlotClickListener { day, section -> + onAddCourse(day, section) + } binding.emptyState.visibility = if (visibleCourses.isEmpty()) View.VISIBLE else View.GONE - binding.btnEmptyAdd.setOnClickListener { onAddCourse() } + binding.btnEmptyAdd.setOnClickListener { onAddCourse(null, null) } + binding.btnEmptyAdd.installPressScale(0.97f) binding.root.contentDescription = binding.root.context.getString(R.string.week_format, week) if (previousWeek != week) { @@ -136,6 +163,95 @@ class WeekPagerAdapter( } } + fun playSelectionMotion(forward: Boolean) { + resetSelectionMotion() + val density = binding.root.resources.displayMetrics.density + + binding.weekDayHeader.apply { + alpha = 0.55f + translationX = (if (forward) 12f else -12f) * density + animate() + .alpha(1f) + .translationX(0f) + .setDuration(360L) + .setInterpolator(settleInterpolator) + .withLayer() + .start() + } + if (binding.emptyState.visibility != View.VISIBLE) return + + binding.emptyState.apply { + alpha = 0f + translationY = 10f * density + animate() + .alpha(1f) + .translationY(0f) + .setDuration(540L) + .setInterpolator(settleInterpolator) + .withLayer() + .start() + } + binding.emptyIconContainer.apply { + alpha = 0f + scaleX = 0.96f + scaleY = 0.96f + rotation = 0f + translationY = 4f * density + animate() + .alpha(1f) + .scaleX(1f) + .scaleY(1f) + .translationY(0f) + .setStartDelay(45L) + .setDuration(620L) + .setInterpolator(settleInterpolator) + .withLayer() + .start() + } + binding.tvEmptyTitle.apply { + alpha = 0f + translationY = 16f * density + animate() + .alpha(1f) + .translationY(0f) + .setStartDelay(145L) + .setDuration(430L) + .setInterpolator(settleInterpolator) + .start() + } + binding.btnEmptyAdd.apply { + alpha = 0f + translationY = 18f * density + animate() + .alpha(1f) + .translationY(0f) + .setStartDelay(230L) + .setDuration(460L) + .setInterpolator(settleInterpolator) + .withLayer() + .start() + } + } + + fun resetSelectionMotion() { + listOf( + binding.weekDayHeader, + binding.scheduleScroll, + binding.emptyState, + binding.emptyIconContainer, + binding.tvEmptyTitle, + binding.btnEmptyAdd + ).forEach { view -> + view.animate().cancel() + view.alpha = 1f + view.scaleX = 1f + view.scaleY = 1f + view.translationX = 0f + view.translationY = 0f + view.rotation = 0f + } + } + private fun bindDayHeaders( week: Int, semester: Semester, @@ -156,6 +272,7 @@ class WeekPagerAdapter( timeInMillis = semester.startDate add(Calendar.DAY_OF_MONTH, (week - 1) * 7) } + binding.tvMonthLabel.text = "${calendar.get(Calendar.MONTH) + 1}\n月" val today = Calendar.getInstance() val todayIndex = when (today.get(Calendar.DAY_OF_WEEK)) { Calendar.MONDAY -> 0 diff --git a/app/src/main/java/com/courseschedule/ui/addcourse/AddCourseActivity.kt b/app/src/main/java/com/courseschedule/ui/addcourse/AddCourseActivity.kt index 19f6d16..814645c 100644 --- a/app/src/main/java/com/courseschedule/ui/addcourse/AddCourseActivity.kt +++ b/app/src/main/java/com/courseschedule/ui/addcourse/AddCourseActivity.kt @@ -22,6 +22,11 @@ import java.util.Calendar class AddCourseActivity : AppCompatActivity() { + companion object { + const val EXTRA_DAY_OF_WEEK = "day_of_week" + const val EXTRA_SECTION = "section" + } + private lateinit var binding: ActivityAddCourseBinding private lateinit var viewModel: CourseViewModel @@ -82,7 +87,9 @@ class AddCourseActivity : AppCompatActivity() { binding.chipMon, binding.chipTue, binding.chipWed, binding.chipThu, binding.chipFri, binding.chipSat, binding.chipSun ) - selectedDay = when (Calendar.getInstance().get(Calendar.DAY_OF_WEEK)) { + selectedDay = intent.getIntExtra(EXTRA_DAY_OF_WEEK, 0).takeIf { it in 1..7 } ?: when ( + Calendar.getInstance().get(Calendar.DAY_OF_WEEK) + ) { Calendar.MONDAY -> 1 Calendar.TUESDAY -> 2 Calendar.WEDNESDAY -> 3 @@ -109,8 +116,9 @@ class AddCourseActivity : AppCompatActivity() { } private fun initDropdowns() { - setupDropdown(binding.spinnerStartSection, sectionOptions, 0) - setupDropdown(binding.spinnerEndSection, sectionOptions, 1) + val selectedSection = intent.getIntExtra(EXTRA_SECTION, 0).takeIf { it in 1..12 } + setupDropdown(binding.spinnerStartSection, sectionOptions, (selectedSection ?: 1) - 1) + setupDropdown(binding.spinnerEndSection, sectionOptions, (selectedSection ?: 2) - 1) } private fun configureWeekDropdowns(totalWeeks: Int) { diff --git a/app/src/main/java/com/courseschedule/ui/importdata/AcademicAdapterRegistry.kt b/app/src/main/java/com/courseschedule/ui/importdata/AcademicAdapterRegistry.kt new file mode 100644 index 0000000..229f3d1 --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/AcademicAdapterRegistry.kt @@ -0,0 +1,579 @@ +package com.courseschedule.ui.importdata + +import com.courseschedule.data.entity.Course +import org.jsoup.Jsoup +import java.net.URI + +internal enum class AcademicCaptureMode { + DOM, + PAGE_GLOBAL, + PAGE_FETCH, + NETWORK_REPLAY +} + +internal data class AcademicAdapterSpec( + val id: String, + val system: AcademicSystem, + val selectors: List = emptyList(), + val globals: List = emptyList(), + val requestPathPrefixes: List = emptyList(), + val captureMode: AcademicCaptureMode +) + +internal sealed interface AcademicAdapterParseResult { + data object NoMatch : AcademicAdapterParseResult + data class Success(val parsed: ParsedImport) : AcademicAdapterParseResult + data class Failure( + val code: AcademicImportErrorCode, + val message: String + ) : AcademicAdapterParseResult +} + +/** + * Local adapter identities are deliberately independent from upstream source labels. + * A source type is provenance; an adapter id selects CourseSchedule-owned capture and + * parsing behavior. + */ +internal object AcademicAdapterRegistry { + const val ZHENGFANG_AUTO = "zhengfang_auto" + const val ZHENGFANG_LEGACY = "zhengfang_legacy" + const val ZHENGFANG_JWGLXT = "zhengfang_jwglxt" + const val QIANGZHI_AUTO = "qiangzhi_auto" + const val QIANGZHI_STANDARD = "qiangzhi_standard" + const val QIANGZHI_2017 = "qiangzhi_2017" + const val QIANGZHI_2024 = "qiangzhi_2024" + const val QIANGZHI_BR = "qiangzhi_br" + const val QIANGZHI_NODE = "qiangzhi_node" + const val QIANGZHI_CRAZY = "qiangzhi_crazy" + const val WISEDU_AUTO = "wisedu_auto" + const val SHUWEI_LOCAL = "shuwei_local" + const val KINGOSOFT_NEW = "kingosoft_new" + const val KINGOSOFT_SELECTED = "kingosoft_selected" + const val SOUTH_SOFT = "south_soft" + const val EAMS_TABLE0 = "eams_table0" + const val SUDA_POST_GRID = "suda_post_grid" + const val ZJU_POST_GRID = "zju_post_grid" + const val XJU_POST_GRID = "xju_post_grid" + const val CUPL_POST_GRID = "cupl_post_grid" + const val SCAU_PRINT_GRID = "scau_print_grid" + const val HITSZ_CARD_GRID = "hitsz_card_grid" + const val HIT_PRINT_GRID = "hit_print_grid" + const val XHTD_BLOCK_GRID = "xhtd_block_grid" + const val UESTC_POST_GRID = "uestc_post_grid" + const val GDEI_NESTED_GRID = "gdei_nested_grid" + + private val specsById = listOf( + AcademicAdapterSpec( + ZHENGFANG_AUTO, AcademicSystem.ZHENGFANG_HTML, + selectors = listOf("#Table1", "#kbgrid_table", "#table1", "#sycjlrtabGrid"), + globals = listOf("veInitDefaultJson", "__INITIAL_STATE__"), + requestPathPrefixes = listOf("/kbcx/xskbcx_cxXskbcxIndex.html"), + captureMode = AcademicCaptureMode.PAGE_FETCH + ), + AcademicAdapterSpec( + ZHENGFANG_LEGACY, AcademicSystem.ZHENGFANG_HTML, + selectors = listOf("#Table1", "#table1"), + captureMode = AcademicCaptureMode.DOM + ), + AcademicAdapterSpec( + ZHENGFANG_JWGLXT, AcademicSystem.ZHENGFANG_HTML, + selectors = listOf("#kbgrid_table", "#sycjlrtabGrid"), + globals = listOf("veInitDefaultJson", "__INITIAL_STATE__"), + requestPathPrefixes = listOf("/kbcx/xskbcx_cxXskbcxIndex.html"), + captureMode = AcademicCaptureMode.PAGE_FETCH + ), + AcademicAdapterSpec( + QIANGZHI_AUTO, AcademicSystem.QIANGZHI_HTML, + selectors = listOf("#kbtable", ".el-table__body", "[name=kbDataTd]"), + captureMode = AcademicCaptureMode.DOM + ), + AcademicAdapterSpec( + QIANGZHI_STANDARD, AcademicSystem.QIANGZHI_HTML, + selectors = listOf("#kbtable .kbcontent", "#kbtable"), + captureMode = AcademicCaptureMode.DOM + ), + AcademicAdapterSpec( + QIANGZHI_2017, AcademicSystem.QIANGZHI_HTML, + selectors = listOf(".el-table__header", ".el-table__body"), + captureMode = AcademicCaptureMode.DOM + ), + AcademicAdapterSpec( + QIANGZHI_2024, AcademicSystem.QIANGZHI_HTML, + selectors = listOf("[name=kbDataTd]", ".qz-toolitiplists"), + captureMode = AcademicCaptureMode.DOM + ), + AcademicAdapterSpec( + QIANGZHI_BR, AcademicSystem.QIANGZHI_HTML, + selectors = listOf("#kbtable .kbcontent", "#kbtable"), + captureMode = AcademicCaptureMode.DOM + ), + AcademicAdapterSpec( + QIANGZHI_NODE, AcademicSystem.QIANGZHI_HTML, + selectors = listOf("#kbtable .kbcontent", "#kbtable"), + captureMode = AcademicCaptureMode.DOM + ), + AcademicAdapterSpec( + QIANGZHI_CRAZY, AcademicSystem.QIANGZHI_HTML, + selectors = listOf("#kbtable .kbcontent1", "#kbtable"), + captureMode = AcademicCaptureMode.DOM + ), + AcademicAdapterSpec( + WISEDU_AUTO, AcademicSystem.WISEDU, + globals = listOf("XNXQDM"), + requestPathPrefixes = listOf( + "/jwapp/sys/wdkb/", "/jwapp/sys/xkjglapp/", "/gsapp/sys/wdkbapp/", + "/jwapp/sys/homeapp/api/home/student/getMyScheduleDetail.do" + ), + captureMode = AcademicCaptureMode.PAGE_FETCH + ), + AcademicAdapterSpec( + SHUWEI_LOCAL, AcademicSystem.SHUWEI, + selectors = listOf("#kbDiv", ".courseInfo", "table"), + globals = listOf("activities", "unitCount", "courseUnits", "table0"), + requestPathPrefixes = listOf("/print-data"), + captureMode = AcademicCaptureMode.NETWORK_REPLAY + ), + AcademicAdapterSpec( + KINGOSOFT_NEW, AcademicSystem.KINGOSOFT_HTML, + selectors = listOf(".pageRpt", "#reportArea", "#mytable"), + captureMode = AcademicCaptureMode.DOM + ), + AcademicAdapterSpec( + KINGOSOFT_SELECTED, AcademicSystem.KINGOSOFT_HTML, + selectors = listOf("#kbDiv", "#mytable", "table"), + captureMode = AcademicCaptureMode.DOM + ), + AcademicAdapterSpec( + SOUTH_SOFT, AcademicSystem.SOUTH_SOFT_HTML, + selectors = listOf("#kb", "table.kb", ".kb"), + captureMode = AcademicCaptureMode.DOM + ), + AcademicAdapterSpec( + SUDA_POST_GRID, AcademicSystem.STRUCTURED_HTML, + selectors = listOf("#DataGrid1", "#MainWork_DataGrid1"), + captureMode = AcademicCaptureMode.DOM + ), + AcademicAdapterSpec( + ZJU_POST_GRID, AcademicSystem.STRUCTURED_HTML, + selectors = listOf("#kcbForm table"), + captureMode = AcademicCaptureMode.DOM + ), + AcademicAdapterSpec( + XJU_POST_GRID, AcademicSystem.STRUCTURED_HTML, + selectors = listOf("#ctl00_contentParent_dgData", "#contentParent_dgData"), + captureMode = AcademicCaptureMode.DOM + ), + AcademicAdapterSpec( + CUPL_POST_GRID, AcademicSystem.STRUCTURED_HTML, + selectors = listOf("#tabCT"), + captureMode = AcademicCaptureMode.DOM + ), + AcademicAdapterSpec( + SCAU_PRINT_GRID, AcademicSystem.STRUCTURED_HTML, + selectors = listOf("table[border=1]"), + captureMode = AcademicCaptureMode.DOM + ), + AcademicAdapterSpec( + HITSZ_CARD_GRID, AcademicSystem.STRUCTURED_HTML, + selectors = listOf(".ivu-table-tbody"), + captureMode = AcademicCaptureMode.DOM + ), + AcademicAdapterSpec( + HIT_PRINT_GRID, AcademicSystem.STRUCTURED_HTML, + selectors = listOf(".xfyq_con", "#xszp"), + captureMode = AcademicCaptureMode.DOM + ), + AcademicAdapterSpec( + XHTD_BLOCK_GRID, AcademicSystem.STRUCTURED_HTML, + selectors = listOf("#kbtable div[id]"), + captureMode = AcademicCaptureMode.DOM + ), + AcademicAdapterSpec( + UESTC_POST_GRID, AcademicSystem.STRUCTURED_HTML, + selectors = listOf("#tbl"), + captureMode = AcademicCaptureMode.DOM + ), + AcademicAdapterSpec( + GDEI_NESTED_GRID, AcademicSystem.STRUCTURED_HTML, + selectors = listOf("tbody"), + captureMode = AcademicCaptureMode.DOM + ), + AcademicAdapterSpec( + EAMS_TABLE0, AcademicSystem.EAMS, + globals = listOf("table0", "unitCount"), + captureMode = AcademicCaptureMode.PAGE_GLOBAL + ) + ).associateBy(AcademicAdapterSpec::id) + + private val sourceTypeAdapters = mapOf( + "zf" to ZHENGFANG_AUTO, + "zf_1" to ZHENGFANG_LEGACY, + "zf_new" to ZHENGFANG_JWGLXT, + "qz" to QIANGZHI_STANDARD, + "qz_2017" to QIANGZHI_2017, + "qz_2024" to QIANGZHI_2024, + "qz_br" to QIANGZHI_BR, + "qz_with_node" to QIANGZHI_NODE, + "qz_crazy" to QIANGZHI_CRAZY, + "jz" to WISEDU_AUTO, + "jz_1" to WISEDU_AUTO, + "jz_x" to WISEDU_AUTO, + "kingo_new" to KINGOSOFT_NEW, + "kg_zx" to KINGOSOFT_SELECTED, + "qingguo" to KINGOSOFT_SELECTED, + "south_soft" to SOUTH_SOFT, + "suda_post" to SUDA_POST_GRID, + "zju_post" to ZJU_POST_GRID, + "xju_post" to XJU_POST_GRID, + "cupl_post" to CUPL_POST_GRID, + "scau" to SCAU_PRINT_GRID, + "hitsz" to HITSZ_CARD_GRID, + "hit" to HIT_PRINT_GRID, + "xhtd" to XHTD_BLOCK_GRID, + "uestc_post" to UESTC_POST_GRID, + "gdei" to GDEI_NESTED_GRID, + "swjtu_post" to WISEDU_AUTO, + "jlict_qz_old" to QIANGZHI_STANDARD + ) + + private val profileAdapters = mapOf( + "zhengfang" to ZHENGFANG_AUTO, + "qiangzhi" to QIANGZHI_AUTO, + "qiangzhi_legacy" to QIANGZHI_STANDARD, + "wisedu" to WISEDU_AUTO, + "shuwei_new" to SHUWEI_LOCAL, + "shuwei_mobile" to SHUWEI_LOCAL, + "shuwei" to SHUWEI_LOCAL, + "shuwei_easy" to SHUWEI_LOCAL, + "kingosoft_new" to KINGOSOFT_NEW, + "kingosoft_selected" to KINGOSOFT_SELECTED, + "south_soft" to SOUTH_SOFT, + "eams" to EAMS_TABLE0 + ) + + fun spec(id: String?): AcademicAdapterSpec? = id?.let(specsById::get) + + fun isKnownAdapterId(id: String?): Boolean = id.isNullOrBlank() || + specsById.containsKey(id) || GenericAcademicImport.profile(id) != null + + fun isCompatible(id: String?, system: AcademicSystem): Boolean = + spec(id)?.system?.let { it == system } ?: isKnownAdapterId(id) + + fun adapterIdFor(sourceType: String?, profileId: String?): String = + sourceType?.let(sourceTypeAdapters::get) + ?: profileId?.let(profileAdapters::get) + ?: profileId.orEmpty() + + fun defaultAdapterId(profileId: String?): String = + profileId?.let(profileAdapters::get) ?: profileId.orEmpty() + + fun selectorsFor(school: AcademicSchool): List = + spec(school.adapterId)?.selectors.orEmpty() + + fun globalsFor(school: AcademicSchool): List = + spec(school.adapterId)?.globals.orEmpty() + + fun captureModeFor(school: AcademicSchool): AcademicCaptureMode = + spec(school.adapterId)?.captureMode ?: AcademicCaptureMode.DOM + + fun allowsReplay(school: AcademicSchool, method: String?, url: String?): Boolean { + if (!method.equals("GET", ignoreCase = true) || !school.allowsTimetable(url)) return false + val uri = runCatching { URI(url) }.getOrNull() ?: return false + return spec(school.adapterId)?.requestPathPrefixes.orEmpty().any { prefix -> + uri.rawPath.orEmpty().contains(prefix, ignoreCase = true) + } + } + + /** Returns null only for legacy profiles that are not yet registry-managed. */ + fun parse(school: AcademicSchool, payload: String, totalWeeks: Int): ParsedImport? { + val adapterId = school.adapterId.ifBlank { + defaultAdapterId(school.genericProfileId) + } + if (spec(adapterId) == null) return null + val result = when (adapterId) { + WISEDU_AUTO -> attempt { + WiseduScheduleParser(totalWeeks, requireExplicitTimes = true).parse(payload) + } + SHUWEI_LOCAL -> withSnapshot(school, payload) { snapshot -> + attempt { + GenericAcademicScheduleParser(totalWeeks).parse( + AcademicSystem.SHUWEI, snapshot.html, snapshot.data + ) + } + } + EAMS_TABLE0 -> withSnapshot(school, payload) { snapshot -> + attempt { EamsScheduleParser().parse(snapshot.data) } + } + SOUTH_SOFT -> withHtmlSnapshot(school, payload, listOf("#kb", "table.kb", ".kb")) { html -> + SouthSoftScheduleParser(totalWeeks).parse(html) + } + KINGOSOFT_NEW -> withHtmlSnapshot( + school, payload, listOf(".pageRpt", "#reportArea", "#mytable") + ) { html -> KingosoftScheduleParser(totalWeeks, selectedResults = false).parse(html) } + KINGOSOFT_SELECTED -> withHtmlSnapshot( + school, payload, listOf("#kbDiv", "#mytable", ".pageRpt", "#reportArea") + ) { html -> KingosoftScheduleParser(totalWeeks, selectedResults = true).parse(html) } + SUDA_POST_GRID -> withHtmlSnapshot( + school, payload, listOf("#DataGrid1", "#MainWork_DataGrid1") + ) { html -> + StrictTimetableTableParser( + totalWeeks, "苏大研究生教务", listOf("#DataGrid1", "#MainWork_DataGrid1"), + orderedRowsAreSections = true, splitDoubleBreaks = true + ).parse(html) + } + ZJU_POST_GRID -> withHtmlSnapshot(school, payload, listOf("#kcbForm table")) { html -> + StrictTimetableTableParser( + totalWeeks, "浙大研究生教务", listOf("#kcbForm table"), + orderedRowsAreSections = true, + splitLeafCourseDivs = true + ).parse(html) + } + XJU_POST_GRID -> withHtmlSnapshot( + school, payload, listOf("#ctl00_contentParent_dgData", "#contentParent_dgData") + ) { html -> + StrictTimetableTableParser( + totalWeeks, "新疆大学研究生教务", + listOf("#ctl00_contentParent_dgData", "#contentParent_dgData"), + orderedRowsAreSections = true, + compactBraceFormat = true, + defaultAllWeeks = true + ).parse(html) + } + CUPL_POST_GRID -> withHtmlSnapshot(school, payload, listOf("#tabCT")) { html -> + StrictTimetableTableParser( + totalWeeks, "法大研究生教务", listOf("#tabCT"), + orderedRowsAreSections = true, + splitBoldBlocks = true + ).parse(html) + } + SCAU_PRINT_GRID -> withHtmlSnapshot(school, payload, listOf("table[border=1]")) { html -> + ScauScheduleParser(totalWeeks).parse(html) + } + HITSZ_CARD_GRID -> withHtmlSnapshot(school, payload, listOf(".ivu-table-tbody")) { html -> + HitszScheduleParser(totalWeeks).parse(html) + } + HIT_PRINT_GRID -> withHtmlSnapshot(school, payload, listOf(".xfyq_con", "#xszp")) { html -> + HitScheduleParser(totalWeeks).parse(html) + } + XHTD_BLOCK_GRID -> withHtmlSnapshot(school, payload, listOf("#kbtable div[id]")) { html -> + XhtdScheduleParser(totalWeeks).parse(html) + } + UESTC_POST_GRID -> withHtmlSnapshot(school, payload, listOf("#tbl")) { html -> + UestcPostScheduleParser(totalWeeks).parse(html) + } + GDEI_NESTED_GRID -> withHtmlSnapshot(school, payload, listOf("tbody")) { html -> + GdeiScheduleParser(totalWeeks).parse(html) + } + ZHENGFANG_LEGACY -> parseZhengfang(school, payload, totalWeeks, legacy = true) + ZHENGFANG_JWGLXT -> parseZhengfang(school, payload, totalWeeks, legacy = false) + ZHENGFANG_AUTO -> parseZhengfangAuto(school, payload, totalWeeks) + QIANGZHI_AUTO -> parseQiangzhiAuto(school, payload, totalWeeks) + QIANGZHI_STANDARD, QIANGZHI_2017, QIANGZHI_2024, + QIANGZHI_BR, QIANGZHI_NODE, QIANGZHI_CRAZY -> + parseQiangzhi(school, payload, totalWeeks, adapterId) + else -> AcademicAdapterParseResult.NoMatch + } + return when (result) { + AcademicAdapterParseResult.NoMatch -> throw ImportFormatException( + "当前页面不属于已登记的本地解析变体", + AcademicImportErrorCode.UNSUPPORTED_VARIANT + ) + is AcademicAdapterParseResult.Failure -> throw ImportFormatException(result.message, result.code) + is AcademicAdapterParseResult.Success -> validate(result.parsed).copy( + sourceLabel = sourceLabel(school, result.parsed.sourceLabel) + ) + } + } + + private fun parseZhengfang( + school: AcademicSchool, + payload: String, + totalWeeks: Int, + legacy: Boolean + ): AcademicAdapterParseResult = withSnapshot(school, payload) { snapshot -> + val document = Jsoup.parse(snapshot.html) + val matched = if (legacy) { + document.selectFirst("#Table1,#table1") != null + } else { + snapshot.data != null || document.selectFirst("#kbgrid_table,#sycjlrtabGrid") != null + } + if (!matched) return@withSnapshot AcademicAdapterParseResult.NoMatch + attempt { + GenericAcademicScheduleParser(totalWeeks).parse( + AcademicSystem.ZHENGFANG_HTML, + snapshot.html, + if (legacy) null else snapshot.data + ) + } + } + + private fun parseZhengfangAuto( + school: AcademicSchool, + payload: String, + totalWeeks: Int + ): AcademicAdapterParseResult = withSnapshot(school, payload) { snapshot -> + val document = Jsoup.parse(snapshot.html) + val candidates = buildList { + if (snapshot.data != null || document.selectFirst("#kbgrid_table,#sycjlrtabGrid") != null) { + add(false) + } + if (document.selectFirst("#Table1,#table1") != null) add(true) + } + resolveVariants(candidates.map { legacy -> + attempt { + GenericAcademicScheduleParser(totalWeeks).parse( + AcademicSystem.ZHENGFANG_HTML, + snapshot.html, + if (legacy) null else snapshot.data + ) + } + }) + } + + private fun parseQiangzhi( + school: AcademicSchool, + payload: String, + totalWeeks: Int, + variantId: String + ): AcademicAdapterParseResult = withSnapshot(school, payload) { snapshot -> + val parser = QiangzhiScheduleParser(totalWeeks, variantId) + val document = Jsoup.parse(snapshot.html) + if (!parser.matches(document)) AcademicAdapterParseResult.NoMatch + else attempt { + parser.parseDocument(document).copy(sourceLabel = qiangzhiSourceLabel(school, snapshot.term)) + } + } + + private fun parseQiangzhiAuto( + school: AcademicSchool, + payload: String, + totalWeeks: Int + ): AcademicAdapterParseResult = withSnapshot(school, payload) { snapshot -> + val document = Jsoup.parse(snapshot.html) + val variants = when { + QiangzhiScheduleParser(totalWeeks, QIANGZHI_2024).matches(document) -> listOf(QIANGZHI_2024) + QiangzhiScheduleParser(totalWeeks, QIANGZHI_2017).matches(document) -> listOf(QIANGZHI_2017) + QiangzhiScheduleParser(totalWeeks, QIANGZHI_CRAZY).matches(document) -> listOf(QIANGZHI_CRAZY) + else -> listOf(QIANGZHI_BR, QIANGZHI_NODE, QIANGZHI_STANDARD).filter { variant -> + QiangzhiScheduleParser(totalWeeks, variant).matches(document) + } + } + resolveVariants(variants.map { variant -> + attempt { + QiangzhiScheduleParser(totalWeeks, variant).parseDocument(document).copy( + sourceLabel = qiangzhiSourceLabel(school, snapshot.term) + ) + } + }) + } + + private inline fun withHtmlSnapshot( + school: AcademicSchool, + payload: String, + selectors: List, + parse: (String) -> ParsedImport + ): AcademicAdapterParseResult = withSnapshot(school, payload) { snapshot -> + val document = Jsoup.parse(snapshot.html) + if (selectors.none { document.selectFirst(it) != null }) AcademicAdapterParseResult.NoMatch + else attempt { parse(snapshot.html) } + } + + private inline fun withSnapshot( + school: AcademicSchool, + payload: String, + block: (AcademicPageSnapshot) -> AcademicAdapterParseResult + ): AcademicAdapterParseResult = try { + block(AcademicPageSnapshot.parse(school, payload)) + } catch (error: ImportFormatException) { + AcademicAdapterParseResult.Failure( + error.errorCode ?: inferErrorCode(error.message), + error.message ?: "课表页面数据不完整" + ) + } + + private inline fun attempt(block: () -> ParsedImport): AcademicAdapterParseResult = try { + AcademicAdapterParseResult.Success(validate(block())) + } catch (error: ImportFormatException) { + AcademicAdapterParseResult.Failure( + error.errorCode ?: inferErrorCode(error.message), + error.message ?: "课表字段不完整" + ) + } catch (_: RuntimeException) { + AcademicAdapterParseResult.Failure( + AcademicImportErrorCode.PARTIAL_PARSE, + "课表结构已匹配,但内容无法完整解析" + ) + } + + internal fun resolveVariants(results: List): AcademicAdapterParseResult { + if (results.isEmpty()) return AcademicAdapterParseResult.NoMatch + results.filterIsInstance().firstOrNull()?.let { return it } + val successes = results.filterIsInstance() + if (successes.isEmpty()) return AcademicAdapterParseResult.NoMatch + val distinct = successes.distinctBy { canonical(it.parsed.courses) } + return if (distinct.size == 1) distinct.first() else AcademicAdapterParseResult.Failure( + AcademicImportErrorCode.AMBIGUOUS_VARIANT, + "多个教务解析变体得到不同结果,请导出脱敏诊断后反馈" + ) + } + + private fun validate(parsed: ParsedImport): ParsedImport { + if (parsed.courses.isEmpty()) { + throw ImportFormatException("当前页面没有课程", AcademicImportErrorCode.NO_TIMETABLE) + } + if (parsed.courses.size > AcademicSchools.MAX_COURSES) { + throw ImportFormatException( + "课表课程数量异常", + AcademicImportErrorCode.PAYLOAD_TOO_LARGE + ) + } + parsed.courses.forEach { course -> + if (course.dayOfWeek !in 1..7) { + throw ImportFormatException("课程星期缺失", AcademicImportErrorCode.MISSING_DAY) + } + if (course.startSection !in 1..30 || course.endSection !in course.startSection..30) { + throw ImportFormatException("课程节次缺失", AcademicImportErrorCode.MISSING_SECTION) + } + if (course.startWeek !in 1..52 || course.endWeek !in course.startWeek..52) { + throw ImportFormatException("课程周次缺失", AcademicImportErrorCode.MISSING_WEEK) + } + } + return parsed + } + + private fun canonical(courses: List): List = courses.map { course -> + listOf( + course.courseName.trim(), course.teacher.trim(), course.classroom.trim(), + course.dayOfWeek, course.startSection, course.endSection, + course.startWeek, course.endWeek, course.weekType + ).joinToString("\u001f") + }.sorted() + + private fun inferErrorCode(message: String?): AcademicImportErrorCode = when { + message.orEmpty().contains("星期") -> AcademicImportErrorCode.MISSING_DAY + message.orEmpty().contains("节次") -> AcademicImportErrorCode.MISSING_SECTION + message.orEmpty().contains("周次") || message.orEmpty().contains("周次位图") -> + AcademicImportErrorCode.MISSING_WEEK + message.orEmpty().contains("过大") || message.orEmpty().contains("过多") -> + AcademicImportErrorCode.PAYLOAD_TOO_LARGE + message.orEmpty().contains("iframe", true) || message.orEmpty().contains("框架") -> + AcademicImportErrorCode.FRAME_BLOCKED + message.orEmpty().contains("未找到") || message.orEmpty().contains("没有课程") -> + AcademicImportErrorCode.NO_TIMETABLE + else -> AcademicImportErrorCode.PARTIAL_PARSE + } + + private fun sourceLabel(school: AcademicSchool, parsedLabel: String): String { + if (school.system == AcademicSystem.QIANGZHI_HTML) return parsedLabel + if (school.system == AcademicSystem.WISEDU) { + return if (school.isGeneric) "通用金智 · ${school.name}" else parsedLabel + } + val family = GenericAcademicImport.profile(school.genericProfileId)?.label + ?: parsedLabel.ifBlank { school.system.name } + return "$family · ${school.name}" + } + + private fun qiangzhiSourceLabel(school: AcademicSchool, term: String): String = + (if (school.isGeneric) "通用强智 · ${school.name}" else "${school.name}教务系统") + + (normalizeWiseduTerm(term)?.let { " · $it" } ?: " · 页面所选学期") +} diff --git a/app/src/main/java/com/courseschedule/ui/importdata/AcademicCaptureScript.kt b/app/src/main/java/com/courseschedule/ui/importdata/AcademicCaptureScript.kt new file mode 100644 index 0000000..77eb3ca --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/AcademicCaptureScript.kt @@ -0,0 +1,361 @@ +package com.courseschedule.ui.importdata + +import com.google.gson.Gson + +/** Captures only schedule-shaped fields and visible timetable markup from the trusted page. */ +internal object AcademicCaptureScript { + private val gson = Gson() + + fun create(school: AcademicSchool): String = SCRIPT + .replace("__ROUTES__", gson.toJson(school.timetablePrefixes)) + .replace("__SCOPED__", school.isGeneric.toString()) + .replace("__PROFILE__", gson.toJson(school.genericProfileId.orEmpty())) + .replace("__SYSTEM__", gson.toJson(school.system.name)) + .replace("__ADAPTER__", gson.toJson(school.adapterId)) + .replace("__CAPTURE_MODE__", gson.toJson(AcademicAdapterRegistry.captureModeFor(school).name)) + .replace("__GLOBALS__", gson.toJson(AcademicAdapterRegistry.globalsFor(school))) + .replace("__SELECTORS__", gson.toJson( + AcademicAdapterRegistry.selectorsFor(school).ifEmpty { selectors(school.system) } + )) + .replace("__LIMIT__", AcademicSchools.MAX_PAYLOAD_CHARS.toString()) + + private fun selectors(system: AcademicSystem): List = when (system) { + AcademicSystem.AIC_HTML -> listOf("table#table") + AcademicSystem.ZHENGFANG_HTML -> listOf("#Table1", "#kbgrid_table", "#table1", "#sycjlrtabGrid") + AcademicSystem.URP_HTML -> listOf(".displayTag", "table.table-striped.table-bordered", "#tb") + AcademicSystem.VATUU_HTML -> listOf("#table_border", ".table_border") + AcademicSystem.UMOOC_HTML -> listOf("#timetable", ".timetable") + AcademicSystem.SOUTH_SOFT_HTML -> listOf("#kb", ".kb") + AcademicSystem.YILIAN_HTML -> listOf("[class*='CourseTimeTable'][class*='timeTable']") + AcademicSystem.KINGOSOFT_HTML -> listOf(".pageRpt", "#reportArea", "#mytable", "#kbDiv", "table") + AcademicSystem.STRUCTURED_HTML -> listOf("table") + else -> listOf("#kbDiv", "table") + } + + private val SCRIPT = """ + (function () { + 'use strict'; + const routes = __ROUTES__; + const selectors = __SELECTORS__; + const profile = __PROFILE__; + const system = __SYSTEM__; + const adapter = __ADAPTER__; + const captureMode = __CAPTURE_MODE__; + const registeredGlobals = __GLOBALS__; + const limit = __LIMIT__; + const visitedDocs = new Set(); + let blockedFrame = false; + const selectorHits = new Set(); + + function scope(url) { + const proxy = url.pathname.match(/^\/(https?(?:-[0-9]+)?)\/([^/]+)(?:\/|${'$'})/i); + if (proxy) return url.origin + '/' + proxy[1] + '/' + proxy[2] + '/'; + if (/^\/(?:https?|wss?|tcp|udp|ftp)(?:[-/]|${'$'})/i.test(url.pathname)) return null; + return url.origin + '/'; + } + function readable(doc) { + try { + const url = new URL(doc.defaultView.location.href); + const page = url.origin + url.pathname; + if (__SCOPED__) return routes.some(function (route) { return scope(url) === route; }); + return routes.some(function (route) { return page.startsWith(route); }); + } catch (_) { return false; } + } + function visible(element) { + if (!element || element.hidden || element.getAttribute('aria-hidden') === 'true') return false; + const style = element.ownerDocument.defaultView.getComputedStyle(element); + return style.display !== 'none' && style.visibility !== 'hidden'; + } + function text(value, max) { + if (value === null || value === undefined) return ''; + if (typeof value !== 'string' && typeof value !== 'number') return ''; + return String(value).slice(0, max || 256); + } + function integer(value) { + const number = Number(value); + return Number.isInteger(number) ? number : null; + } + function array(value) { return Array.isArray(value) ? value : []; } + function object(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : null; + } + function findArray(root, predicate, depth, seen) { + if (!root || depth > 10) return null; + if (typeof root === 'object') { + if (seen.has(root) || seen.size > 3000) return null; + seen.add(root); + } + if (Array.isArray(root)) { + if (root.some(function (item) { return object(item) && predicate(item); })) return root; + for (let i = 0; i < Math.min(root.length, 600); i++) { + const found = findArray(root[i], predicate, depth + 1, seen); + if (found) return found; + } + } else if (object(root)) { + const keys = Object.keys(root).slice(0, 160); + for (const key of keys) { + const found = findArray(root[key], predicate, depth + 1, seen); + if (found) return found; + } + } + return null; + } + function findObject(root, predicate, depth, seen) { + if (!root || depth > 10) return null; + if (typeof root === 'object') { + if (seen.has(root) || seen.size > 3000) return null; + seen.add(root); + } + if (object(root) && predicate(root)) return root; + const values = Array.isArray(root) ? root.slice(0, 600) : object(root) ? + Object.keys(root).slice(0, 160).map(function (key) { return root[key]; }) : []; + for (const value of values) { + const found = findObject(value, predicate, depth + 1, seen); + if (found) return found; + } + return null; + } + function projectUrp(root) { + const holder = findObject(root, function (value) { return Array.isArray(value.dateList); }, 0, new Set()); + if (holder) { + return {dateList: holder.dateList.slice(0, 80).map(function (date) { + return {selectCourseList: array(date && date.selectCourseList).slice(0, 500).map(function (course) { + return { + courseName: text(course && course.courseName), + attendClassTeacher: text(course && course.attendClassTeacher), + timeAndPlaceList: array(course && course.timeAndPlaceList).slice(0, 100).map(function (time) { + return {campusName: text(time && time.campusName), classDay: integer(time && time.classDay), + classSessions: integer(time && time.classSessions), classWeek: text(time && time.classWeek, 64), + classroomName: text(time && time.classroomName), continuingSession: integer(time && time.continuingSession), + teachingBuildingName: text(time && time.teachingBuildingName)}; + }) + }; + })}; + })}; + } + const rows = findArray(root, function (item) { return 'kcm' in item && object(item.id) && 'skzc' in item.id; }, 0, new Set()); + if (!rows) return null; + return rows.slice(0, 1000).map(function (item) { + return {kcm: text(item.kcm), jsm: text(item.jsm), jxlm: text(item.jxlm), jasm: text(item.jasm), + cxjc: integer(item.cxjc), id: {skxq: integer(item.id.skxq), skzc: text(item.id.skzc, 64), + skjc: integer(item.id.skjc)}}; + }); + } + function projectRows(root, required, fields) { + const rows = findArray(root, function (item) { + return required.every(function (key) { return key in item; }); + }, 0, new Set()); + if (!rows) return null; + return rows.slice(0, 1500).map(function (item) { + const source = object(item) || {}; + const clean = {}; + fields.forEach(function (key) { clean[key] = text(source[key], key === 'weeks' || key === 'qmz' || key === 'zc' ? 512 : 256); }); + return clean; + }); + } + function projectShuwei(root) { + const holder = findObject(root, function (value) { return Array.isArray(value.activities); }, 0, new Set()); + if (!holder) return null; + const hasCurrent = holder.activities.some(function (item) { + return object(item) && 'courseName' in item && 'weekday' in item && + 'startUnit' in item && 'endUnit' in item && Array.isArray(item.weekIndexes); + }); + if (hasCurrent) { + return {activities: holder.activities.slice(0, 1500).map(function (item) { + const source = object(item) || {}; + const teachers = array(source.teachers).length ? array(source.teachers).slice(0, 20) + .map(function (teacher) { return text(teacher); }).join(',') : text(source.teachers); + return {courseName: text(source.courseName), room: text(source.room), teachers: teachers, + weekday: integer(source.weekday), startUnit: integer(source.startUnit), endUnit: integer(source.endUnit), + weekIndexes: array(source.weekIndexes).slice(0, 52).map(integer).filter(function (week) { return week !== null; })}; + })}; + } + const units = integer(holder.unitCount) || array(holder.courseUnits).length; + return {unitCount: units, activities: holder.activities.slice(0, 240).map(function (cell) { + return array(cell).slice(0, 30).map(function (item) { + return {courseName: text(item && item.courseName), teacherName: text(item && item.teacherName), + roomName: text(item && item.roomName), vaildWeeks: text(item && item.vaildWeeks, 64)}; + }); + })}; + } + function projectZhengfang(root) { + const names = ['kcmc','kcm','courseName']; + const days = ['xqj','xq','skxq','day','weekday']; + const sections = ['jcs','jc','sksj','ksjc','skjc','startSection']; + const weeks = ['zcd','zc','skzc','weeks']; + function hasAny(item, fields) { + return fields.some(function (key) { return key in item; }); + } + const rows = findArray(root, function (item) { + return hasAny(item, names) && hasAny(item, days) && + hasAny(item, sections) && hasAny(item, weeks); + }, 0, new Set()); + if (!rows) return null; + const fields = names.concat(days, sections, ['jsjc','endSection','cxjc','sectionCount'], weeks, + ['xm','jsxm','jsmc','teacher','cdmc','jxcdmc','jasmc','classroom','room']); + return rows.slice(0, 1500).map(function (item) { + const source = object(item) || {}; + const clean = {}; + fields.forEach(function (key) { + if (key in source) clean[key] = text(source[key], key === 'zcd' || key === 'zc' || key === 'skzc' || key === 'weeks' ? 512 : 256); + }); + return clean; + }); + } + function project(root) { + if (!root) return null; + if (system === 'URP_NEW') return projectUrp(root); + if (system === 'CHENGFANG') return projectRows(root, ['kcmc','xq','jcdm2','zcs'], + ['kcmc','xq','jcdm2','zcs','jxcdmcs','teaxms']); + if (system === 'EAMS' || system === 'SHUWEI') return projectShuwei(root); + if (system === 'ZHENGFANG_HTML') return projectZhengfang(root); + if (system === 'XBELL') return projectRows(root, ['kcmc','xqj','djj','qmz'], + ['kcmc','xqj','djj','qmz','dsz','jsxm','skdd']); + if (system === 'CHAOXING') return projectRows(root, ['kcmc','xq','djc','zc'], + ['kcmc','xq','djc','zc','zctype','tmc','croommc']); + if (system === 'CHAOXING_SHARE') return projectRows(root, ['name','dayOfWeek','beginNumber','length','weeks'], + ['name','dayOfWeek','beginNumber','length','weeks','teacherName','location','onlineLocation']); + return null; + } + function rawJson(doc) { + try { + const body = doc.body; + if (!body || body.children.length > 1) return null; + const only = body.firstElementChild; + if (only && only.tagName !== 'PRE') return null; + const raw = (only ? only.textContent : body.textContent || '').trim(); + if (raw.length < 2 || raw.length > limit / 2 || !/^[\[{]/.test(raw)) return null; + return JSON.parse(raw); + } catch (_) { return null; } + } + function projectedData(doc) { + const win = doc.defaultView; + const candidates = [rawJson(doc)]; + registeredGlobals.forEach(function (name) { + try { candidates.push(win[name]); } catch (_) {} + }); + try { candidates.push(win.kbxx); } catch (_) {} + try { candidates.push({dateList: win.dateList}); } catch (_) {} + try { candidates.push({activities: win.activities, unitCount: win.unitCount, courseUnits: win.courseUnits}); } catch (_) {} + try { + if (win.table0 && typeof win.table0 === 'object') { + const table = Array.isArray(win.table0) + ? {activities: win.table0.slice(0, 3000), unitCount: integer(win.unitCount)} + : Object.assign({}, win.table0); + if (!Array.isArray(win.table0) && Number.isInteger(Number(win.unitCount))) { + table.unitCount = Number(win.unitCount); + } + candidates.push(table); + } + } catch (_) {} + try { candidates.push(win.veInitDefaultJson); } catch (_) {} + try { candidates.push({kckbData: win.kckbData}); } catch (_) {} + try { candidates.push({lessonArray: win.lessonArray}); } catch (_) {} + try { candidates.push(win.__INITIAL_STATE__); } catch (_) {} + try { candidates.push(win.__NEXT_DATA__); } catch (_) {} + for (const candidate of candidates) { + try { + let source = candidate; + if (typeof source === 'string' && /^[\[{]/.test(source.trim()) && source.length <= limit / 2) { + source = JSON.parse(source); + } + const clean = project(source); + if (clean && JSON.stringify(clean).length > 4) return clean; + } catch (_) {} + } + return null; + } + function scheduleScore(node) { + const value = (node.innerText || node.textContent || '').slice(0, 6000); + const days = (value.match(/(?:星期|周)[一二三四五六日天]/g) || []).length; + let score = Math.min(days, 7) * 2; + if (/周次|\d+\s*(?:[-~至]\s*\d+)?\s*周/.test(value)) score += 4; + if (/节次|(?:第|\[)?\s*\d+\s*(?:[-~至]\s*\d+)?\s*节/.test(value)) score += 3; + if (/课程|教师|教室|上课地点/.test(value)) score += 2; + if (node.querySelector && node.querySelector('.courseInfo,.weekDetail,[title*=周次],[aria-describedby$=_kcmc]')) score += 6; + return score; + } + function candidateNodes(doc) { + const nodes = []; + selectors.forEach(function (selector) { + try { doc.querySelectorAll(selector).forEach(function (node) { + if (visible(node) && scheduleScore(node) >= 7 && !nodes.includes(node)) { + nodes.push(node); + selectorHits.add(selector); + } + }); } catch (_) {} + }); + return nodes.slice(0, 8); + } + const tags = new Set(['TABLE','THEAD','TBODY','TFOOT','TR','TD','TH','DIV','SPAN','FONT','BR','HR','P','B','STRONG','I','EM','SMALL']); + const attributes = ['id','class','title','rowspan','colspan','aria-describedby','data-original-title']; + let copied = 0; + function copy(node, depth, outputDoc) { + if (depth > 36 || ++copied > 18000) throw new Error('limit'); + if (node.nodeType === 3) return outputDoc.createTextNode((node.nodeValue || '').slice(0, 4096)); + if (node.nodeType !== 1 || !tags.has(node.tagName) || !visible(node)) return null; + const clean = outputDoc.createElement(node.tagName.toLowerCase()); + attributes.forEach(function (name) { + if (node.hasAttribute(name)) clean.setAttribute(name, node.getAttribute(name).slice(0, 256)); + }); + node.childNodes.forEach(function (child) { + const safe = copy(child, depth + 1, outputDoc); + if (safe) clean.appendChild(safe); + }); + return clean; + } + function selectedTerm(doc) { + const candidates = doc.querySelectorAll('select[id*=xnxq i],select[name*=xnxq i],select[id*=term i],select[name*=semester i]'); + for (const selector of candidates) { + if (selector.selectedIndex >= 0) { + const value = text(selector.value, 80); + const label = text(selector.options[selector.selectedIndex].textContent, 80); + if (/20\d{2}/.test(value + label)) return value || label; + } + } + return ''; + } + function captureDiagnostics() { + return {adapterId: adapter, captureMode: captureMode, + selectorHits: Array.from(selectorHits).slice(0, 16), + iframeState: blockedFrame ? 'blocked' : (visitedDocs.size > 1 ? 'same_origin' : 'none'), + documentsVisited: visitedDocs.size}; + } + function find(doc, depth) { + if (!doc || depth > 5 || visitedDocs.has(doc) || visitedDocs.size >= 24) return null; + visitedDocs.add(doc); + if (readable(doc)) { + const data = projectedData(doc); + const nodes = candidateNodes(doc); + if (data || nodes.length) return {doc: doc, data: data, nodes: nodes}; + } + for (const frame of doc.querySelectorAll('iframe,frame')) { + if (!visible(frame)) continue; + try { + const child = frame.contentDocument; + if (!child) { blockedFrame = true; continue; } + const found = find(child, depth + 1); + if (found) return found; + } catch (_) { blockedFrame = true; } + } + return null; + } + try { + const found = find(document, 0); + if (!found) return JSON.stringify({error: blockedFrame ? 'frame' : 'table', + diagnostics: captureDiagnostics()}); + const wrapper = document.implementation.createHTMLDocument('schedule').createElement('div'); + found.nodes.forEach(function (node) { + const safe = copy(node, 0, wrapper.ownerDocument); + if (safe) wrapper.appendChild(safe); + }); + const source = new URL(found.doc.defaultView.location.href); + const result = JSON.stringify({html: wrapper.innerHTML, data: found.data, + term: selectedTerm(found.doc), sourceUrl: source.origin + source.pathname, profile: profile, + diagnostics: captureDiagnostics()}); + return result.length > limit ? JSON.stringify({error: 'large', + diagnostics: captureDiagnostics()}) : result; + } catch (_) { return JSON.stringify({error: 'capture', diagnostics: captureDiagnostics()}); } + })(); + """.trimIndent() +} diff --git a/app/src/main/java/com/courseschedule/ui/importdata/AcademicImportDiagnostics.kt b/app/src/main/java/com/courseschedule/ui/importdata/AcademicImportDiagnostics.kt new file mode 100644 index 0000000..06e963d --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/AcademicImportDiagnostics.kt @@ -0,0 +1,267 @@ +package com.courseschedule.ui.importdata + +import com.google.gson.GsonBuilder +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import org.jsoup.Jsoup +import java.net.URI +import java.util.Locale + +/** Builds a preview-safe diagnostic. Raw page content is never copied into the result. */ +internal object AcademicImportDiagnostics { + const val SCHEMA_VERSION = 1 + private const val MAX_TABLES = 12 + private const val MAX_JSON_DEPTH = 6 + + fun create( + appVersion: String, + school: AcademicSchool, + payload: String?, + error: Throwable?, + currentUrl: String? = null, + captureMode: String? = null + ): String { + val placeholders = PlaceholderMap() + val parsed = payload?.takeIf { it.length <= AcademicSchools.MAX_PAYLOAD_CHARS } + ?.let { runCatching { JsonParser.parseString(it) }.getOrNull() } + val wrapper = parsed?.takeIf(JsonElement::isJsonObject)?.asJsonObject + val html = wrapper?.get("html")?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString } + ?.asString.orEmpty() + val data = wrapper?.get("data")?.takeUnless(JsonElement::isJsonNull) + ?: parsed?.takeUnless { wrapper?.has("html") == true } + val sourceUrl = wrapper?.get("sourceUrl") + ?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString } + ?.asString ?: currentUrl + val capture = wrapper?.get("diagnostics")?.takeIf(JsonElement::isJsonObject)?.asJsonObject + + val root = JsonObject().apply { + addProperty("schemaVersion", SCHEMA_VERSION) + addProperty("appVersion", safeVersion(appVersion)) + addProperty("schoolDirectoryId", safeIdentifier(school.id)) + addProperty("family", school.system.name) + addProperty("adapterId", safeIdentifier(school.adapterId.ifBlank { + AcademicAdapterRegistry.defaultAdapterId(school.genericProfileId) + })) + addProperty("verification", if (school.verified) "verified" else "experimental") + addProperty("source", safeUrl(sourceUrl)) + addProperty( + "captureMode", + captureMode?.takeIf(::safeEnumToken) + ?: capture?.safeString("captureMode")?.takeIf(::safeEnumToken) + ?: AcademicAdapterRegistry.captureModeFor(school).name + ) + addProperty("errorCode", errorCode(error).name) + add("capture", captureShape(school, capture)) + add("tables", tableShapes(html)) + add("jsonShape", jsonShape(data, 0, placeholders)) + } + return GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create().toJson(root) + } + + private fun captureShape(school: AcademicSchool, capture: JsonObject?): JsonObject = JsonObject().apply { + val registered = AcademicAdapterRegistry.selectorsFor(school).toSet() + val hits = capture?.getAsJsonArray("selectorHits")?.mapNotNull { item -> + item.takeIf(JsonElement::isJsonPrimitive)?.asString?.takeIf(registered::contains) + }.orEmpty().distinct().take(16) + add("selectorHits", JsonArray().also { array -> hits.forEach(array::add) }) + val frame = capture?.safeString("iframeState") + ?.takeIf { it in setOf("none", "same_origin", "blocked") } ?: "unknown" + addProperty("iframeState", frame) + val visited = capture?.get("documentsVisited")?.takeIf(JsonElement::isJsonPrimitive) + ?.asString?.toIntOrNull()?.coerceIn(0, 24) + visited?.let { addProperty("documentsVisited", it) } + } + + private fun tableShapes(html: String): JsonArray { + val result = JsonArray() + if (html.isBlank()) return result + val document = runCatching { Jsoup.parse(html) }.getOrNull() ?: return result + document.select("script,style,link,form,input,textarea,select,button,iframe,frame,object,embed") + .remove() + document.select("table").take(MAX_TABLES).forEach { table -> + val rows = table.select("tr").toList().filter { it.closest("table") === table }.take(240) + var cells = 0 + var maxColumns = 0 + var rowSpans = 0 + var columnSpans = 0 + val headers = linkedSetOf() + rows.forEach { row -> + val direct = row.children().toList().filter { it.tagName() == "td" || it.tagName() == "th" } + cells += direct.size + maxColumns = maxOf(maxColumns, direct.sumOf { + it.attr("colspan").toIntOrNull()?.coerceIn(1, 32) ?: 1 + }) + rowSpans += direct.count { (it.attr("rowspan").toIntOrNull() ?: 1) > 1 } + columnSpans += direct.count { (it.attr("colspan").toIntOrNull() ?: 1) > 1 } + direct.filter { it.tagName() == "th" || row === rows.firstOrNull() }.forEach { cell -> + canonicalHeader(cell.text())?.let(headers::add) + } + } + result.add(JsonObject().apply { + addProperty("rows", rows.size) + addProperty("maxColumns", maxColumns) + addProperty("cells", cells.coerceAtMost(18_000)) + addProperty("rowspanCells", rowSpans) + addProperty("colspanCells", columnSpans) + add("headers", JsonArray().also { array -> headers.take(32).forEach(array::add) }) + }) + } + return result + } + + private fun jsonShape( + value: JsonElement?, + depth: Int, + placeholders: PlaceholderMap + ): JsonElement { + if (value == null || value.isJsonNull) return JsonObject().apply { addProperty("type", "null") } + if (depth >= MAX_JSON_DEPTH) return JsonObject().apply { addProperty("type", typeOf(value)) } + return when { + value.isJsonObject -> JsonObject().apply { + addProperty("type", "object") + val fields = JsonArray() + value.asJsonObject.entrySet().asSequence() + .filterNot { (rawKey, _) -> isSensitiveKey(rawKey) } + .take(160).forEach { (rawKey, child) -> + fields.add(JsonObject().apply { + addProperty("name", safeFieldName(rawKey, placeholders)) + addProperty("type", typeOf(child)) + if (child.isJsonObject || child.isJsonArray) { + add("shape", jsonShape(child, depth + 1, placeholders)) + } else if (child.isJsonPrimitive) { + addProperty("sample", safeSample(rawKey, child, placeholders)) + } + }) + } + add("fields", fields) + } + value.isJsonArray -> JsonObject().apply { + val array = value.asJsonArray + addProperty("type", "array") + addProperty("length", array.size().coerceAtMost(10_000)) + add("itemTypes", JsonArray().also { types -> + array.take(100).map(::typeOf).distinct().forEach(types::add) + }) + array.firstOrNull()?.let { add("itemShape", jsonShape(it, depth + 1, placeholders)) } + } + else -> JsonObject().apply { + addProperty("type", typeOf(value)) + addProperty("sample", safeSample("", value, placeholders)) + } + } + } + + private fun safeSample(key: String, value: JsonElement, placeholders: PlaceholderMap): String { + if (!value.isJsonPrimitive) return typeOf(value) + val primitive = value.asJsonPrimitive + if (primitive.isBoolean) return primitive.asBoolean.toString() + if (primitive.isNumber) { + return if (key.lowercase(Locale.ROOT) in NUMERIC_SCHEDULE_FIELDS) { + primitive.asString.take(4) + } else "" + } + val raw = primitive.asString.take(512) + if (key.lowercase(Locale.ROOT) in FORMAT_FIELDS && SAFE_SCHEDULE_FORMAT.matches(raw)) { + return raw + } + return placeholders.text(raw) + } + + private fun safeUrl(raw: String?): String { + val uri = runCatching { URI(raw) }.getOrNull() ?: return "unavailable" + val scheme = uri.scheme?.lowercase(Locale.ROOT)?.takeIf { it == "https" || it == "http" } + ?: return "unavailable" + val host = uri.host?.lowercase(Locale.ROOT)?.takeIf { + it.matches(Regex("[a-z0-9.-]{1,253}")) + } ?: return "unavailable" + val path = uri.rawPath.orEmpty().split('/').joinToString("/") { segment -> + when { + segment.isEmpty() -> "" + segment.length > 40 || segment.matches(Regex("(?=.*\\d{4})[A-Za-z0-9._~-]+")) -> ":segment" + segment.matches(Regex("[A-Za-z0-9._~-]{1,40}")) -> segment + else -> ":segment" + } + }.ifBlank { "/" } + return "$scheme://$host$path" + } + + private fun safeFieldName(raw: String, placeholders: PlaceholderMap): String = + raw.takeIf { it.matches(Regex("[A-Za-z][A-Za-z0-9_.-]{0,63}")) } + ?: canonicalHeader(raw) + ?: placeholders.field(raw) + + private fun canonicalHeader(raw: String): String? { + val normalized = raw.trim().lowercase(Locale.ROOT).replace(Regex("[\\s_/-]"), "") + return HEADER_TERMS[normalized] + } + + private fun safeIdentifier(raw: String): String = raw.take(80).takeIf { + it.matches(Regex("[A-Za-z0-9_-]+")) + } ?: "unknown" + + private fun safeVersion(raw: String): String = raw.take(32).takeIf { + it.matches(Regex("[A-Za-z0-9.+_-]+")) + } ?: "unknown" + + private fun safeEnumToken(raw: String): Boolean = raw.matches(Regex("[A-Z_]{1,32}")) + + private fun isSensitiveKey(raw: String): Boolean { + val key = raw.lowercase(Locale.ROOT).filter(Char::isLetterOrDigit) + return SENSITIVE_KEY_PARTS.any(key::contains) + } + + private fun errorCode(error: Throwable?): AcademicImportErrorCode = + (error as? ImportFormatException)?.errorCode ?: when { + error?.message.orEmpty().contains("星期") -> AcademicImportErrorCode.MISSING_DAY + error?.message.orEmpty().contains("节次") -> AcademicImportErrorCode.MISSING_SECTION + error?.message.orEmpty().contains("周次") -> AcademicImportErrorCode.MISSING_WEEK + error?.message.orEmpty().contains("过大") -> AcademicImportErrorCode.PAYLOAD_TOO_LARGE + error?.message.orEmpty().contains("框架") -> AcademicImportErrorCode.FRAME_BLOCKED + else -> AcademicImportErrorCode.CAPTURE_MISS + } + + private fun typeOf(value: JsonElement): String = when { + value.isJsonNull -> "null" + value.isJsonObject -> "object" + value.isJsonArray -> "array" + value.asJsonPrimitive.isBoolean -> "boolean" + value.asJsonPrimitive.isNumber -> "number" + else -> "string" + } + + private fun JsonObject.safeString(key: String): String? = get(key) + ?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString } + ?.asString?.take(80) + + private class PlaceholderMap { + private val texts = linkedMapOf() + private val fields = linkedMapOf() + fun text(value: String): String = texts.getOrPut(value) { "" } + fun field(value: String): String = fields.getOrPut(value) { "" } + } + + private val SAFE_SCHEDULE_FORMAT = Regex( + "[0-9一二三四五六日天单双周星期礼拜节第,,、;;::()()\\[\\]{}~~\\-—–-至/\\s]{1,128}" + ) + private val FORMAT_FIELDS = setOf( + "weeks", "week", "weekindexes", "vaildweeks", "skzc", "zcd", "zc", "qmz", + "sections", "section", "jcs", "jc", "sksj", "jcdm2" + ) + private val NUMERIC_SCHEDULE_FIELDS = setOf( + "weekday", "day", "dayofweek", "xqj", "skxq", "xq", "startunit", "endunit", + "startsection", "endsection", "skjc", "jsjc", "cxjc", "unitcount" + ) + private val SENSITIVE_KEY_PARTS = setOf( + "cookie", "password", "passwd", "pwd", "authorization", "token", "ticket", + "session", "storage", "script", "href", "src", "form" + ) + private val HEADER_TERMS = listOf( + "课程", "课程名", "课程名称", "教师", "老师", "任课教师", "授课教师", + "教室", "地点", "上课地点", "星期", "周几", "上课日", "周次", "上课周", + "节次", "上课节次", "开始节次", "结束节次", "单双周", + "course", "coursename", "teacher", "classroom", "room", "location", + "weekday", "day", "week", "weeks", "section", "sections" + ).associateBy { it.lowercase(Locale.ROOT).replace(Regex("[\\s_/-]"), "") } +} diff --git a/app/src/main/java/com/courseschedule/ui/importdata/AcademicSchool.kt b/app/src/main/java/com/courseschedule/ui/importdata/AcademicSchool.kt new file mode 100644 index 0000000..cb12f68 --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/AcademicSchool.kt @@ -0,0 +1,296 @@ +package com.courseschedule.ui.importdata + +import com.google.gson.JsonParser +import java.net.URI +import java.net.InetAddress +import java.util.Locale + +enum class AcademicSystem { + WISEDU, + QIANGZHI_HTML, + ZHENGFANG_HTML, + URP_NEW, + URP_HTML, + AIC_HTML, + CHENGFANG, + VATUU_HTML, + UMOOC_HTML, + XBELL, + EAMS, + SHUWEI, + SOUTH_SOFT_HTML, + YILIAN_HTML, + KINGOSOFT_HTML, + CHAOXING, + CHAOXING_SHARE, + STRUCTURED_HTML +} + +/** Audited, bundled school entries. A URL supplied by a webpage never extends this allowlist. */ +data class AcademicSchool( + val id: String, + val name: String, + val system: AcademicSystem, + val loginUrl: String, + val trustedHosts: Set, + val verified: Boolean, + val timetableUrl: String = loginUrl, + val loginPrefixes: List = emptyList(), + val timetablePrefixes: List = emptyList(), + val authenticationPrefixes: List = emptyList(), + val webVpnHost: String? = null, + val genericProfileId: String? = null, + /** Local parser/capture variant. sourceType remains catalog provenance only. */ + val adapterId: String = "", + val allowCleartext: Boolean = false, + val cleartextHosts: Set = emptySet(), + val allowNonDefaultPort: Boolean = false +) { + val isGeneric: Boolean get() = id == GenericAcademicImport.SCHOOL_ID + + fun allowsNavigation(url: String?): Boolean { + val uri = academicWebUri(url, allowCleartext, allowNonDefaultPort) ?: return false + val host = uri.host.lowercase(Locale.ROOT) + if (uri.scheme.equals("http", ignoreCase = true) && host !in cleartextHosts) return false + if (host !in trustedHosts) { + // School pages commonly hand authentication to another HTTPS subdomain + // owned by the same university. It may navigate, but allowsTimetable() + // still limits capture to the original exact scope. + return uri.scheme.equals("https", ignoreCase = true) && + trustedHosts.any { sameEduCnInstitution(it, host) } + } + if (isGeneric) { + val scope = academicUrlScope(url, allowCleartext, allowNonDefaultPort) ?: return false + return scope in loginPrefixes || scope in timetablePrefixes || scope in authenticationPrefixes + } + if (host != webVpnHost) return true + + // WebVPN shares one origin between many upstream sites. Do not trust arbitrary proxy paths. + val path = uri.rawPath.orEmpty() + if (Regex("^/(?:https?|wss?|tcp|udp|ftp)(?:[-/]|$)", RegexOption.IGNORE_CASE).containsMatchIn(path)) { + return (timetablePrefixes + authenticationPrefixes).any { pageUrl(uri).startsWith(it) } + } + return true + } + + fun allowsTimetable(url: String?): Boolean { + val uri = academicWebUri(url, allowCleartext, allowNonDefaultPort) ?: return false + if (isGeneric) return allowsNavigation(url) && + academicUrlScope(url, allowCleartext, allowNonDefaultPort) in timetablePrefixes + return allowsNavigation(url) && timetablePrefixes.any { pageUrl(uri).startsWith(it) } + } + + private fun pageUrl(uri: URI) = "${uri.scheme.lowercase(Locale.ROOT)}://${academicAuthority(uri)}" + + uri.rawPath.orEmpty().ifBlank { "/" } + +} + +internal fun academicWebUri( + url: String?, + allowCleartext: Boolean = false, + allowNonDefaultPort: Boolean = false +): URI? { + if (url.isNullOrBlank() || url.length > 8192 || '\\' in url) return null + val uri = runCatching { URI(url) }.getOrNull() ?: return null + val scheme = uri.scheme?.lowercase(Locale.ROOT) + if (scheme != "https" && !(allowCleartext && scheme == "http")) return null + val host = uri.host?.lowercase(Locale.ROOT) ?: return null + if (uri.rawUserInfo != null || host.endsWith('.') || '.' !in host || isIpAddress(host)) return null + val defaultPort = if (scheme == "https") 443 else 80 + if (uri.port != -1 && (!allowNonDefaultPort && uri.port != defaultPort || uri.port !in 1..65535)) return null + val path = uri.rawPath.orEmpty() + if (uri.normalize().rawPath != path || "//" in path || + Regex("%(?:2e|2f|5c|00)", RegexOption.IGNORE_CASE).containsMatchIn(path)) return null + return uri +} + +internal fun academicHttpsUri(url: String?): URI? = academicWebUri(url) + +/** A WebVPN upstream is its own scope, even when it shares a host with the portal. */ +internal fun academicUrlScope( + url: String?, + allowCleartext: Boolean = false, + allowNonDefaultPort: Boolean = false +): String? { + val uri = academicWebUri(url, allowCleartext, allowNonDefaultPort) ?: return null + val origin = "${uri.scheme.lowercase(Locale.ROOT)}://${academicAuthority(uri)}" + val path = uri.rawPath.orEmpty() + val proxy = Regex("^/(https?(?:-\\d+)?)/([^/]+)(?:/|$)", RegexOption.IGNORE_CASE).find(path) + if (proxy != null) return "$origin/${proxy.groupValues[1]}/${proxy.groupValues[2]}/" + if (Regex("^/(?:https?|wss?|tcp|udp|ftp)(?:[-/]|$)", RegexOption.IGNORE_CASE).containsMatchIn(path)) return null + return "$origin/" +} + +internal fun academicAuthority(uri: URI): String { + val scheme = uri.scheme.lowercase(Locale.ROOT) + val defaultPort = if (scheme == "https") 443 else 80 + val port = uri.port.takeIf { it != -1 && it != defaultPort }?.let { ":$it" }.orEmpty() + return uri.host.lowercase(Locale.ROOT) + port +} + +/** Upgrade an insecure redirect only when its HTTPS equivalent is already trusted. */ +internal fun upgradedAcademicHttpsUrl(school: AcademicSchool, url: String?): String? { + val uri = academicWebUri(url, allowCleartext = true) ?: return null + if (!uri.scheme.equals("http", ignoreCase = true)) return null + val upgraded = buildString { + append("https://") + append(uri.host.lowercase(Locale.ROOT)) + append(uri.rawPath.orEmpty().ifBlank { "/" }) + uri.rawQuery?.let { append('?').append(it) } + } + return upgraded.takeIf(school::allowsNavigation) +} + +private fun isIpAddress(host: String): Boolean = runCatching { + // Avoid DNS resolution: only values composed like IP literals reach InetAddress. + if (':' !in host && !host.matches(Regex("[0-9]+(?:\\.[0-9]+){3}"))) return@runCatching false + InetAddress.getByName(host) + true +}.getOrDefault(false) + +private fun sameEduCnInstitution(first: String, second: String): Boolean { + fun root(host: String): String? { + val labels = host.lowercase(Locale.ROOT).split('.') + if (labels.size < 3 || labels.takeLast(2) != listOf("edu", "cn")) return null + return labels.takeLast(3).joinToString(".") + } + return root(first)?.let { it == root(second) } == true +} + +object AcademicSchools { + const val MAX_PAYLOAD_CHARS = 400_000 + const val MAX_COURSES = 1_500 + + val SWPU = AcademicSchool( + id = "swpu", + name = "西南石油大学", + system = AcademicSystem.WISEDU, + loginUrl = "https://deanservices.swpu.edu.cn/jwapp/sys/jwauthapp/login/index.html", + trustedHosts = setOf("swpu.edu.cn", "deanservices.swpu.edu.cn"), + verified = true, + adapterId = AcademicAdapterRegistry.WISEDU_AUTO, + loginPrefixes = listOf("https://deanservices.swpu.edu.cn/"), + timetablePrefixes = listOf("https://deanservices.swpu.edu.cn/jwapp/") + ) + + // Wengine's public URL encoding for jw.sdufe.edu.cn; this is not a session token. + // Keep the two upstreams separate: the identity provider may navigate, but may never be captured. + private const val SDUFE_JW_ROUTE = "77726476706e69737468656265737421fae00f8f23256e55300d8db9d6562d" + private const val SDUFE_AUTH_ROUTE = "77726476706e69737468656265737421f9f352d234347d567b468ca88d1b203b" + val SDUFE = AcademicSchool( + id = "sdufe", + name = "山东财经大学", + system = AcademicSystem.QIANGZHI_HTML, + loginUrl = "https://webvpn.sdufe.edu.cn/login", + trustedHosts = setOf("webvpn.sdufe.edu.cn", "jw.sdufe.edu.cn", "ids.sdufe.edu.cn"), + webVpnHost = "webvpn.sdufe.edu.cn", + verified = false, + adapterId = AcademicAdapterRegistry.QIANGZHI_STANDARD, + loginPrefixes = listOf("https://webvpn.sdufe.edu.cn/"), + timetableUrl = "https://webvpn.sdufe.edu.cn/http/$SDUFE_JW_ROUTE/", + timetablePrefixes = listOf( + "https://jw.sdufe.edu.cn/", + "https://webvpn.sdufe.edu.cn/http/$SDUFE_JW_ROUTE/", + "https://webvpn.sdufe.edu.cn/https/$SDUFE_JW_ROUTE/" + ), + // CAS first redirects through /http/ and then /https/ for this same identity + // upstream. Both browser connections still use HTTPS to the school WebVPN. + authenticationPrefixes = listOf( + "https://webvpn.sdufe.edu.cn/http/$SDUFE_AUTH_ROUTE/", + "https://webvpn.sdufe.edu.cn/https/$SDUFE_AUTH_ROUTE/" + ) + ) + + val NUAA = AcademicSchool( + id = "nuaa", + name = "南京航空航天大学", + system = AcademicSystem.EAMS, + loginUrl = "https://aao-eas.nuaa.edu.cn/eams/login.action", + trustedHosts = setOf("aao-eas.nuaa.edu.cn", "authserver.nuaa.edu.cn"), + verified = false, + adapterId = AcademicAdapterRegistry.EAMS_TABLE0, + loginPrefixes = listOf("https://aao-eas.nuaa.edu.cn/"), + timetablePrefixes = listOf("https://aao-eas.nuaa.edu.cn/eams/"), + authenticationPrefixes = listOf("https://authserver.nuaa.edu.cn/authserver/") + ) + + val NUAA_GRADUATE = AcademicSchool( + id = "nuaa_graduate", + name = "南京航空航天大学 - 研究生", + system = AcademicSystem.SOUTH_SOFT_HTML, + loginUrl = "https://graduate.nuaa.edu.cn/gmis5/home/stulogin", + trustedHosts = setOf("graduate.nuaa.edu.cn", "authserver.nuaa.edu.cn"), + verified = false, + adapterId = AcademicAdapterRegistry.SOUTH_SOFT, + loginPrefixes = listOf("https://graduate.nuaa.edu.cn/"), + timetablePrefixes = listOf("https://graduate.nuaa.edu.cn/gmis5/"), + authenticationPrefixes = listOf("https://authserver.nuaa.edu.cn/authserver/") + ) + + val all: List = listOf(SWPU, SDUFE, NUAA, NUAA_GRADUATE) + fun find(id: String?): AcademicSchool? = all.firstOrNull { it.id == id } + + fun parse(school: AcademicSchool, payload: String, totalWeeks: Int): ParsedImport { + if (payload.length > MAX_PAYLOAD_CHARS) throw ImportFormatException( + "教务数据过大,无法安全导入", + AcademicImportErrorCode.PAYLOAD_TOO_LARGE + ) + AcademicAdapterRegistry.parse(school, payload, totalWeeks)?.let { return it } + val parsed = when (school.system) { + AcademicSystem.WISEDU -> WiseduScheduleParser(totalWeeks, requireExplicitTimes = school.isGeneric).parse(payload).let { parsed -> + if (school.isGeneric) parsed.copy(sourceLabel = "通用金智 · ${school.name}") else parsed + } + AcademicSystem.QIANGZHI_HTML -> { + val snapshot = AcademicPageSnapshot.parse(school, payload) + QiangzhiScheduleParser(totalWeeks).parse(snapshot.html).copy( + sourceLabel = (if (school.isGeneric) "通用强智 · ${school.name}" else "${school.name}教务系统") + + (normalizeWiseduTerm(snapshot.term)?.let { " · $it" } ?: " · 页面所选学期") + ) + } + else -> { + val snapshot = AcademicPageSnapshot.parse(school, payload) + val profile = GenericAcademicImport.profile(school.genericProfileId) + val parsed = GenericAcademicScheduleParser(totalWeeks).parse( + system = school.system, + html = snapshot.html, + data = snapshot.data + ) + val source = profile?.label ?: school.system.name + parsed.copy(sourceLabel = "$source · ${school.name}" + + (normalizeWiseduTerm(snapshot.term)?.let { " · $it" } ?: " · 页面所选学期")) + } + } + if (parsed.courses.size > MAX_COURSES) { + throw ImportFormatException( + "课表课程数量异常", + AcademicImportErrorCode.PAYLOAD_TOO_LARGE + ) + } + return parsed + } +} + +internal data class AcademicPageSnapshot( + val html: String, + val term: String, + val data: com.google.gson.JsonElement? +) { + companion object { + fun parse(school: AcademicSchool, payload: String): AcademicPageSnapshot { + val root = runCatching { JsonParser.parseString(payload).asJsonObject }.getOrNull() + ?: throw ImportFormatException("课表页面数据格式不正确,请重新读取") + val url = root.get("sourceUrl")?.takeIf { it.isJsonPrimitive }?.asString + if (!school.allowsTimetable(url)) { + throw ImportFormatException( + "只能读取本次确认的教务域名和路径", + AcademicImportErrorCode.UNTRUSTED_SOURCE + ) + } + return AcademicPageSnapshot( + html = root.get("html")?.takeIf { it.isJsonPrimitive }?.asString.orEmpty(), + term = root.get("term")?.takeIf { it.isJsonPrimitive }?.asString.orEmpty(), + data = root.get("data")?.takeUnless { it.isJsonNull } + ) + } + } +} diff --git a/app/src/main/java/com/courseschedule/ui/importdata/AcademicSchoolAdapter.kt b/app/src/main/java/com/courseschedule/ui/importdata/AcademicSchoolAdapter.kt new file mode 100644 index 0000000..53b2686 --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/AcademicSchoolAdapter.kt @@ -0,0 +1,77 @@ +package com.courseschedule.ui.importdata + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.courseschedule.R +import com.courseschedule.databinding.ItemAcademicSchoolBinding + +internal class AcademicSchoolAdapter( + private val onClick: (AcademicSchoolDirectoryEntry) -> Unit +) : ListAdapter(DIFF) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SchoolViewHolder = SchoolViewHolder( + ItemAcademicSchoolBinding.inflate(LayoutInflater.from(parent.context), parent, false) + ) + + override fun onBindViewHolder(holder: SchoolViewHolder, position: Int) = holder.bind(getItem(position)) + + inner class SchoolViewHolder( + private val binding: ItemAcademicSchoolBinding + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(entry: AcademicSchoolDirectoryEntry) = with(binding) { + tvSchoolName.text = entry.name + val profileLabel = entry.profile?.label + ?: root.context.getString(R.string.academic_adapter_required_label) + val hostLabel = entry.host.ifBlank { + root.context.getString( + if (entry.needsUserUrl) R.string.academic_enter_school_url + else R.string.academic_no_independent_address + ) + } + tvSchoolMeta.text = root.context.getString( + if (entry.allowCleartext) R.string.academic_school_meta_cleartext else R.string.academic_school_meta, + profileLabel, + hostLabel + ) + tvSchoolStatus.setText( + if (entry.needsUserUrl) R.string.academic_status_url_required else when (entry.support) { + AcademicDirectorySupport.VERIFIED -> R.string.academic_status_verified + AcademicDirectorySupport.COMPATIBLE -> R.string.academic_status_compatible + AcademicDirectorySupport.EXPERIMENTAL -> R.string.academic_status_experimental + AcademicDirectorySupport.ADAPTER_REQUIRED -> R.string.academic_status_adapter_required + } + ) + tvSchoolStatus.setTextColor(ContextCompat.getColor( + root.context, + if (entry.needsUserUrl) R.color.secondary_variant else when (entry.support) { + AcademicDirectorySupport.VERIFIED, + AcademicDirectorySupport.COMPATIBLE -> R.color.primary + AcademicDirectorySupport.EXPERIMENTAL -> R.color.secondary_variant + AcademicDirectorySupport.ADAPTER_REQUIRED -> R.color.error + } + )) + root.alpha = if (entry.canImport || entry.needsUserUrl) 1f else 0.72f + root.contentDescription = root.context.getString( + R.string.academic_school_accessibility, + entry.name, + profileLabel, + tvSchoolStatus.text + ) + root.setOnClickListener { onClick(entry) } + } + } + + private companion object { + val DIFF = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: AcademicSchoolDirectoryEntry, newItem: AcademicSchoolDirectoryEntry) = + oldItem.id == newItem.id + + override fun areContentsTheSame(oldItem: AcademicSchoolDirectoryEntry, newItem: AcademicSchoolDirectoryEntry) = + oldItem == newItem + } + } +} diff --git a/app/src/main/java/com/courseschedule/ui/importdata/AcademicSchoolDirectory.kt b/app/src/main/java/com/courseschedule/ui/importdata/AcademicSchoolDirectory.kt new file mode 100644 index 0000000..d730d99 --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/AcademicSchoolDirectory.kt @@ -0,0 +1,245 @@ +package com.courseschedule.ui.importdata + +import android.content.Context +import android.icu.text.Transliterator +import android.os.Build +import com.google.gson.Gson +import java.util.Locale + +internal enum class AcademicDirectorySupport { + VERIFIED, + COMPATIBLE, + EXPERIMENTAL, + ADAPTER_REQUIRED; + + companion object { + fun parse(value: String?, schemaVersion: Int): AcademicDirectorySupport? = when (value) { + "verified" -> VERIFIED + "compatible" -> COMPATIBLE + "experimental" -> EXPERIMENTAL + "adapter_required" -> ADAPTER_REQUIRED + null -> if (schemaVersion == 1) COMPATIBLE else null + else -> null + } + } +} + +internal enum class AcademicDirectoryCategory { + UNDERGRADUATE, + GRADUATE, + COMMON; + + companion object { + fun parse(value: String?, schemaVersion: Int): AcademicDirectoryCategory? = when (value) { + "undergraduate" -> UNDERGRADUATE + "graduate" -> GRADUATE + "common" -> COMMON + null -> if (schemaVersion == 1) UNDERGRADUATE else null + else -> null + } + } +} + +internal data class AcademicSchoolDirectoryEntry( + val id: String, + val name: String, + val profileId: String, + val url: String, + val aliases: List = emptyList(), + val builtInSchoolId: String? = null, + val support: AcademicDirectorySupport = AcademicDirectorySupport.COMPATIBLE, + val category: AcademicDirectoryCategory = AcademicDirectoryCategory.UNDERGRADUATE, + val sourceType: String = "", + val adapterId: String = "", + val allowCleartext: Boolean = false, + val loginUrls: List = emptyList(), + val authenticationUrls: List = emptyList(), + val timetableUrls: List = emptyList(), + /** WakeUp's recorded entry address; shown as guidance only, never loaded. */ + val referenceUrl: String = "" +) { + val profile: GenericAcademicProfile? get() = GenericAcademicImport.profile(profileId) + val host: String get() = academicWebUri(url, allowCleartext, allowNonDefaultPort = true)?.host.orEmpty() + val canImport: Boolean get() = support != AcademicDirectorySupport.ADAPTER_REQUIRED && profile != null && host.isNotEmpty() + val needsUserUrl: Boolean get() = support == AcademicDirectorySupport.ADAPTER_REQUIRED && profile != null + val verified: Boolean get() = support == AcademicDirectorySupport.VERIFIED + + /** WakeUp used its remote parser for this row; reviewed rows may also have a local fallback. */ + val isCloudOnly: Boolean get() = WakeUpImportCatalog.isCloudOnly(sourceType) + + /** School- or type-specific usage note adapted from WakeUp's import steps. */ + val importHint: String? get() = WakeUpImportCatalog.hintFor(name, sourceType) + + fun toAcademicSchool(): AcademicSchool? = builtInSchoolId?.let(AcademicSchools::find) + ?: profile?.takeIf { canImport }?.let { + runCatching { + GenericAcademicImport.createCatalog( + it, url, allowCleartext, authenticationUrls, timetableUrls, adapterId, loginUrls + ).copy(name = name) + }.getOrNull() + } + + fun createUserConfiguredSchool(address: String): AcademicSchool { + val definition = profile?.takeIf { needsUserUrl } + ?: throw ImportFormatException("该条目没有可复用的本地解析器") + return GenericAcademicImport.createCatalog( + profile = definition, + address = address, + allowCleartext = address.trim().startsWith("http://", ignoreCase = true), + adapterId = adapterId + ).copy(name = name) + } + + fun matches(query: String): Boolean { + val needle = normalizeSearch(query) + if (needle.isEmpty()) return true + return sequenceOf(name, host, profile?.label.orEmpty(), sourceType) + .plus(aliases.asSequence()) + .any { normalizeSearch(it).contains(needle) } + } +} + +/** Bundled, reviewable directory. Entries are candidates; parsing success is still verified in preview. */ +internal object AcademicSchoolDirectory { + private const val ASSET_NAME = "academic_school_directory.json" + private const val MAX_ASSET_CHARS = 1_500_000 + private const val MAX_ENTRIES = 5_000 + private val idPattern = Regex("[A-Za-z0-9_-]{1,80}") + // Han-Latin rules are only guaranteed from API 29; below that, alias + // romanization silently stays disabled (same outcome as the runCatching). + private val romanizer: Transliterator? by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + Transliterator.getInstance("Han-Latin; Latin-ASCII; Lower()") else null + } + + @Volatile private var cachedEntries: List? = null + + fun entries(context: Context): List = cachedEntries + ?: synchronized(this) { + cachedEntries ?: load(context).also { cachedEntries = it } + } + + fun find(context: Context, id: String?): AcademicSchoolDirectoryEntry? = + id?.let { wanted -> entries(context).firstOrNull { it.id == wanted } } + + fun search(entries: List, query: String): List = + if (query.isBlank()) entries else entries.filter { it.matches(query) } + + internal fun parse(json: String): List { + if (json.length > MAX_ASSET_CHARS) return emptyList() + val file = runCatching { Gson().fromJson(json, DirectoryFile::class.java) }.getOrNull() + ?: return emptyList() + if (file.schemaVersion !in 1..3 || file.entries.size > MAX_ENTRIES) return emptyList() + val seenIds = HashSet() + return file.entries.mapNotNull { raw -> + val id = raw.id?.trim().orEmpty() + val name = raw.name?.trim().orEmpty() + val sourceType = raw.sourceType?.trim().orEmpty().take(48) + val profile = GenericAcademicImport.profile(raw.profile?.trim()) + ?: sourceType.takeIf(String::isNotBlank)?.let(WakeUpImportCatalog::profileFor) + val sourceUrl = raw.url?.trim().orEmpty() + val adapterId = raw.adapterId?.trim().orEmpty().ifBlank { + AcademicAdapterRegistry.adapterIdFor(sourceType, profile?.id) + } + val declaredSupport = AcademicDirectorySupport.parse(raw.support, file.schemaVersion) + ?: return@mapNotNull null + val support = declaredSupport + val category = AcademicDirectoryCategory.parse(raw.category, file.schemaVersion) ?: return@mapNotNull null + val allowCleartext = raw.cleartext == true + if (!idPattern.matches(id) || !seenIds.add(id) || name.length !in 2..80 || + name.any(Char::isISOControl) || !AcademicAdapterRegistry.isKnownAdapterId(adapterId) || + profile != null && !AcademicAdapterRegistry.isCompatible(adapterId, profile.system)) { + return@mapNotNull null + } + val school = if (support == AcademicDirectorySupport.ADAPTER_REQUIRED) null else { + val usableProfile = profile ?: return@mapNotNull null + runCatching { + GenericAcademicImport.createCatalog( + usableProfile, sourceUrl, allowCleartext, raw.authenticationUrls.orEmpty(), + raw.timetableUrls.orEmpty(), adapterId, raw.loginUrls.orEmpty() + ) + }.getOrNull() + ?: return@mapNotNull null + } + AcademicSchoolDirectoryEntry( + id = id, + name = name, + profileId = profile?.id.orEmpty(), + url = school?.loginUrl.orEmpty(), + aliases = raw.aliases.orEmpty().asSequence() + .map(String::trim) + .filter { it.length in 1..40 && it.none(Char::isISOControl) } + .distinct() + .take(8) + .toList(), + support = support, + category = category, + sourceType = sourceType, + adapterId = adapterId, + allowCleartext = allowCleartext && school?.allowCleartext == true, + loginUrls = school?.loginPrefixes.orEmpty(), + authenticationUrls = school?.authenticationPrefixes.orEmpty(), + timetableUrls = school?.timetablePrefixes.orEmpty(), + referenceUrl = raw.referenceUrl?.trim().orEmpty() + .replace(Regex(";jsessionid=[^/?#;]*", RegexOption.IGNORE_CASE), "") + .substringBefore('?').substringBefore('#') + .filterNot(Char::isISOControl).take(240) + ) + } + } + + private fun load(context: Context): List { + val builtIns = AcademicSchools.all.map { school -> + AcademicSchoolDirectoryEntry( + id = school.id, + name = school.name, + profileId = GenericAcademicImport.profiles.first { it.system == school.system }.id, + url = school.loginUrl, + builtInSchoolId = school.id, + adapterId = school.adapterId, + support = if (school.verified) AcademicDirectorySupport.VERIFIED + else AcademicDirectorySupport.COMPATIBLE + ).withRomanizedName() + } + val bundled = runCatching { + context.assets.open(ASSET_NAME).bufferedReader().use { parse(it.readText()) } + }.getOrDefault(emptyList()) + val builtInNames = builtIns.mapTo(HashSet()) { it.name } + return builtIns + bundled.filterNot { it.name in builtInNames } + .map { it.withRomanizedName() } + .sortedBy { normalizeSearch(it.aliases.lastOrNull().orEmpty()) } + } + + private fun AcademicSchoolDirectoryEntry.withRomanizedName(): AcademicSchoolDirectoryEntry { + val romanizer = romanizer ?: return this + val romanized = runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) romanizer.transliterate(name) else "" + }.getOrDefault("").trim() + return if (romanized.isBlank()) this else copy(aliases = (aliases + romanized).distinct()) + } + + private data class DirectoryFile( + val schemaVersion: Int = 0, + val entries: List = emptyList() + ) + + private data class RawEntry( + val id: String? = null, + val name: String? = null, + val profile: String? = null, + val url: String? = null, + val aliases: List? = null, + val support: String? = null, + val category: String? = null, + val sourceType: String? = null, + val adapterId: String? = null, + val cleartext: Boolean? = null, + val loginUrls: List? = null, + val authenticationUrls: List? = null, + val timetableUrls: List? = null, + val referenceUrl: String? = null + ) +} + +private fun normalizeSearch(value: String): String = value.lowercase(Locale.ROOT) + .filterNot { it.isWhitespace() || it == '-' || it == '_' } diff --git a/app/src/main/java/com/courseschedule/ui/importdata/AcademicWebImportActivity.kt b/app/src/main/java/com/courseschedule/ui/importdata/AcademicWebImportActivity.kt new file mode 100644 index 0000000..fef6289 --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/AcademicWebImportActivity.kt @@ -0,0 +1,1304 @@ +package com.courseschedule.ui.importdata + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Typeface +import android.net.Uri +import android.net.http.SslError +import android.os.Bundle +import android.os.SystemClock +import android.view.View +import android.view.ViewGroup +import android.view.animation.OvershootInterpolator +import android.view.animation.PathInterpolator +import android.webkit.CookieManager +import android.webkit.SslErrorHandler +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebResourceError +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Toast +import android.widget.ScrollView +import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.courseschedule.R +import com.courseschedule.databinding.ActivityAcademicWebImportBinding +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import org.json.JSONObject +import org.json.JSONTokener +import java.util.UUID +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.util.Locale +import kotlin.coroutines.resume + +class AcademicWebImportActivity : AppCompatActivity() { + + companion object { + const val EXTRA_SCHEDULE_JSON = "schedule_json" + const val EXTRA_TOTAL_WEEKS = "total_weeks" + const val EXTRA_SCHOOL_ID = "school_id" + private const val EXTRA_GENERIC_SYSTEM = "generic_system" + private const val EXTRA_GENERIC_URL = "generic_url" + private const val EXTRA_GENERIC_PROFILE = "generic_profile" + private const val EXTRA_GENERIC_ADAPTER = "generic_adapter" + private const val EXTRA_GENERIC_NAME = "generic_name" + private const val EXTRA_GENERIC_CLEARTEXT = "generic_cleartext" + private const val EXTRA_GENERIC_AUTH_URLS = "generic_auth_urls" + private const val EXTRA_GENERIC_LOGIN_URLS = "generic_login_urls" + private const val EXTRA_GENERIC_TIMETABLE_URLS = "generic_timetable_urls" + const val EXTRA_GENERIC_HINT = "generic_hint" + private const val MIN_IDENTIFYING_DISPLAY_MILLIS = 320L + private const val MIN_PARSING_DISPLAY_MILLIS = 360L + private const val SUCCESS_DISPLAY_MILLIS = 1_100L + private const val REQUEST_TIMEOUT_MILLIS = 45_000L + private val SAFE_REPLAY_HEADERS = setOf( + "accept", "accept-language", "user-agent", "x-requested-with" + ) + + internal fun schoolFromIntent(intent: Intent): AcademicSchool? { + val id = intent.getStringExtra(EXTRA_SCHOOL_ID) + if (id != GenericAcademicImport.SCHOOL_ID) return AcademicSchools.find(id) + return runCatching { + val profile = intent.getStringExtra(EXTRA_GENERIC_PROFILE) + val allowCleartext = intent.getBooleanExtra(EXTRA_GENERIC_CLEARTEXT, false) + val created = if (profile != null) { + val definition = GenericAcademicImport.profile(profile) + ?: throw ImportFormatException("不支持的教务系统类型") + GenericAcademicImport.createCatalog( + definition, + intent.getStringExtra(EXTRA_GENERIC_URL).orEmpty(), + allowCleartext, + intent.getStringArrayListExtra(EXTRA_GENERIC_AUTH_URLS).orEmpty(), + intent.getStringArrayListExtra(EXTRA_GENERIC_TIMETABLE_URLS).orEmpty(), + intent.getStringExtra(EXTRA_GENERIC_ADAPTER) + ?: AcademicAdapterRegistry.defaultAdapterId(profile), + intent.getStringArrayListExtra(EXTRA_GENERIC_LOGIN_URLS).orEmpty() + ) + } + else GenericAcademicImport.create( + AcademicSystem.valueOf(intent.getStringExtra(EXTRA_GENERIC_SYSTEM).orEmpty()), + intent.getStringExtra(EXTRA_GENERIC_URL).orEmpty()) + intent.getStringExtra(EXTRA_GENERIC_NAME)?.takeIf(String::isNotBlank) + ?.let { created.copy(name = it) } ?: created + }.getOrNull() + } + + internal fun schoolIntent(context: Context, school: AcademicSchool): Intent = + Intent(context, AcademicWebImportActivity::class.java) + .putExtra(EXTRA_SCHOOL_ID, school.id).apply { + if (school.isGeneric) { + putExtra(EXTRA_GENERIC_SYSTEM, school.system.name) + putExtra(EXTRA_GENERIC_URL, school.loginUrl) + putExtra(EXTRA_GENERIC_NAME, school.name) + putExtra(EXTRA_GENERIC_CLEARTEXT, school.allowCleartext) + putStringArrayListExtra( + EXTRA_GENERIC_AUTH_URLS, + ArrayList(school.authenticationPrefixes) + ) + putStringArrayListExtra( + EXTRA_GENERIC_LOGIN_URLS, + ArrayList(school.loginPrefixes) + ) + putStringArrayListExtra( + EXTRA_GENERIC_TIMETABLE_URLS, + ArrayList(school.timetablePrefixes) + ) + putExtra(EXTRA_GENERIC_ADAPTER, school.adapterId) + school.genericProfileId?.let { putExtra(EXTRA_GENERIC_PROFILE, it) } + } + } + } + + private lateinit var binding: ActivityAcademicWebImportBinding + @Volatile private lateinit var school: AcademicSchool + private var activeRequestToken: String? = null + private var timeoutJob: Job? = null + private var resultPollJob: Job? = null + private var externalNavigationDialog: androidx.appcompat.app.AlertDialog? = null + private var sslErrorDialog: androidx.appcompat.app.AlertDialog? = null + private val acceptedSslHosts = mutableSetOf() + private var pageHadError = false + private var pendingDiagnostic: String? = null + private val diagnosticExporter = registerForActivityResult( + ActivityResultContracts.CreateDocument("application/json") + ) { uri -> + val diagnostic = pendingDiagnostic ?: return@registerForActivityResult + if (uri == null) return@registerForActivityResult + lifecycleScope.launch(Dispatchers.IO) { + val saved = runCatching { + contentResolver.openOutputStream(uri, "w")?.bufferedWriter()?.use { writer -> + writer.write(diagnostic) + } ?: error("无法创建诊断文件") + }.isSuccess + withContext(Dispatchers.Main) { + Toast.makeText( + this@AcademicWebImportActivity, + if (saved) R.string.academic_diagnostic_saved else R.string.academic_diagnostic_save_failed, + Toast.LENGTH_SHORT + ).show() + } + } + } + + /** Latest /print-data JSON body replayed from the trusted origin (new ShuWei). */ + @Volatile private var shuweiStash: Pair? = null + private val statusInterpolator = PathInterpolator(0.22f, 1f, 0.36f, 1f) + private val totalWeeks by lazy { + intent.getIntExtra(EXTRA_TOTAL_WEEKS, 20).coerceIn(1, 52) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val selectedSchool = schoolFromIntent(intent) + if (selectedSchool == null) { + finish() + return + } + school = selectedSchool + binding = ActivityAcademicWebImportBinding.inflate(layoutInflater) + setContentView(binding.root) + + val genericHost = academicWebUri( + school.loginUrl, + school.allowCleartext, + school.allowNonDefaultPort + )?.host + binding.toolbar.title = if (!school.isGeneric || school.name != genericHost) { + getString(R.string.academic_school_title, school.name) + } else getString(R.string.academic_generic_title) + if (school.isGeneric) binding.toolbar.subtitle = school.name + setSupportActionBar(binding.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + binding.toolbar.setNavigationOnClickListener { + if (binding.webView.canGoBack()) binding.webView.goBack() else finish() + } + + configureWebView() + val readsDisplayedPage = school.system != AcademicSystem.WISEDU + binding.tvSchoolHint.visibility = if (readsDisplayedPage || school.isGeneric) View.VISIBLE else View.GONE + binding.btnOpenSchool.visibility = if (readsDisplayedPage || school.isGeneric) View.VISIBLE else View.GONE + if (!school.isGeneric) { + when (school.id) { + AcademicSchools.NUAA.id -> binding.tvSchoolHint.setText(R.string.academic_nuaa_hint) + AcademicSchools.NUAA_GRADUATE.id -> binding.tvSchoolHint.setText(R.string.academic_nuaa_graduate_hint) + } + } else { + val profile = GenericAcademicImport.profile(school.genericProfileId) + val profileHint = getString( + R.string.academic_generic_profile_hint, + profile?.label ?: school.system.name, + profile?.instructions ?: getString(R.string.academic_generic_check_preview) + ) + binding.tvSchoolHint.text = if (school.allowCleartext) { + getString(R.string.academic_cleartext_inline_hint, profileHint) + } else profileHint + binding.btnOpenSchool.setText(R.string.academic_return_entry) + } + // Per-school usage note adapted from WakeUp's import steps. + intent.getStringExtra(EXTRA_GENERIC_HINT)?.takeIf(String::isNotBlank)?.let { hint -> + binding.tvSchoolHint.text = buildString { + append(binding.tvSchoolHint.text) + append("\n\n") + append(getString(R.string.academic_school_hint_label, hint)) + } + } + binding.btnOpenSchool.setOnClickListener { + binding.webView.loadUrl(school.timetableUrl) + } + if (readsDisplayedPage) binding.btnFetchSchedule.setText(R.string.academic_read_displayed_term) + binding.btnFetchSchedule.setOnClickListener { fetchCurrentSchedule() } + setImportStatus(false, getString(R.string.academic_waiting_login)) + binding.webView.loadUrl(school.loginUrl) + } + + @SuppressLint("SetJavaScriptEnabled") + private fun configureWebView() { + binding.webView.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + allowFileAccess = false + allowContentAccess = false + mixedContentMode = android.webkit.WebSettings.MIXED_CONTENT_NEVER_ALLOW + javaScriptCanOpenWindowsAutomatically = false + setSupportMultipleWindows(false) + @Suppress("DEPRECATION") + saveFormData = false + } + binding.webView.webChromeClient = WebChromeClient() + binding.webView.importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS + binding.webView.webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { + return handleNavigation(view, request?.url?.toString()) + } + + @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") + override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean { + return handleNavigation(view, url) + } + + override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? { + if (request != null && school.isGeneric && school.system == AcademicSystem.SHUWEI && + AcademicAdapterRegistry.allowsReplay( + school, request.method, request.url.toString() + )) { + val capture = captureShuweiPrintData(request) + if (capture != null) return capture + } + return super.shouldInterceptRequest(view, request) + } + + /** Replays a /print-data GET with the session cookie, stashes the JSON, serves it unchanged. */ + private fun captureShuweiPrintData(request: WebResourceRequest): WebResourceResponse? { + val url = request.url.toString() + if (!AcademicAdapterRegistry.allowsReplay(school, request.method, url) || + !ShuweiPrintDataCapture.isCapturableUrl(url, school.allowCleartext)) return null + // A new term request supersedes the old cache even when this request fails. + shuweiStash = null + val connection = runCatching { + (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 10_000 + readTimeout = 20_000 + // Never carry the authenticated replay across an HTTP redirect. + instanceFollowRedirects = false + requestMethod = "GET" + CookieManager.getInstance().getCookie(url) + ?.takeIf(String::isNotBlank) + ?.let { setRequestProperty("Cookie", it) } + request.requestHeaders.forEach { (name, value) -> + if (name.lowercase() in SAFE_REPLAY_HEADERS) { + setRequestProperty(name, value) + } + } + } + }.getOrNull() ?: return null + return runCatching { + connection.connect() + if (connection.responseCode != HttpURLConnection.HTTP_OK) return null + val input = connection.inputStream + val output = ByteArrayOutputStream() + val buffer = ByteArray(16_384) + var total = 0 + while (true) { + val read = input.read(buffer) + if (read < 0) break + total += read + if (total > ShuweiPrintDataCapture.MAX_STASH_BYTES) return null + output.write(buffer, 0, read) + } + val body = output.toByteArray() + val text = String(body, Charsets.UTF_8) + shuweiStash = text to url + val (mime, encoding) = ShuweiPrintDataCapture.splitContentType( + connection.contentType + ) + WebResourceResponse(mime, encoding, 200, "OK", emptyMap(), + ByteArrayInputStream(body)) + }.getOrElse { + runCatching { connection.disconnect() } + null + } + } + + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + shuweiStash = null + pageHadError = false + invalidateRequest() + if (school.isGeneric) binding.toolbar.subtitle = webNavigationHost(url) + setImportStatus(false, getString(R.string.academic_waiting_login)) + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + if (!pageHadError && activeRequestToken == null && isWebNavigationUrl(url)) { + setImportStatus(false, getString(R.string.academic_open_timetable_hint)) + } + } + + override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { + if (request?.isForMainFrame == true) { + pageHadError = true + invalidateRequest() + setImportStatus(false, getString(R.string.academic_page_load_failed)) + } + } + + override fun onReceivedHttpError(view: WebView?, request: WebResourceRequest?, errorResponse: WebResourceResponse?) { + if (request?.isForMainFrame == true) { + pageHadError = true + invalidateRequest() + setImportStatus(false, getString(R.string.academic_page_load_failed)) + } + } + + override fun onReceivedSslError( + view: WebView?, + handler: SslErrorHandler?, + error: SslError? + ) { + handleSslError(handler, error) + } + } + } + + private fun handleSslError(handler: SslErrorHandler?, error: SslError?) { + handler ?: return + val url = error?.url.orEmpty() + val host = webNavigationHost(url) + if (host == null) { + handler.cancel() + pageHadError = true + invalidateRequest() + setImportStatus(false, getString(R.string.academic_ssl_error)) + return + } + if (host in acceptedSslHosts) { + handler.proceed() + return + } + if (sslErrorDialog != null || isFinishing || isDestroyed) { + handler.cancel() + return + } + pageHadError = true + invalidateRequest() + setImportStatus(false, getString(R.string.academic_ssl_warning)) + var handled = false + fun reject() { + if (handled) return + handled = true + handler.cancel() + setImportStatus(false, getString(R.string.academic_ssl_error)) + } + sslErrorDialog = MaterialAlertDialogBuilder(this) + .setTitle(R.string.academic_ssl_warning_title) + .setMessage(getString(R.string.academic_ssl_warning_detail, host)) + .setPositiveButton(R.string.academic_ssl_continue) { _, _ -> + handled = true + acceptedSslHosts += host + pageHadError = false + handler.proceed() + setImportStatus(false, getString(R.string.academic_waiting_login)) + } + .setNegativeButton(R.string.cancel) { _, _ -> reject() } + .setOnCancelListener { reject() } + .setOnDismissListener { + if (!handled) reject() + sslErrorDialog = null + } + .create().also { it.show() } + } + + private fun handleNavigation(view: WebView?, url: String?): Boolean { + val target = url?.trim().orEmpty() + return when (navigationScheme(target)) { + "http", "https", "about", "data", "javascript", "blob" -> false + null -> { + showLinkError(R.string.academic_link_invalid) + true + } + else -> { + confirmExternalNavigation(view ?: binding.webView, target) + true + } + } + } + + private fun confirmExternalNavigation(view: WebView, url: String) { + if (isFinishing || isDestroyed || externalNavigationDialog != null) return + val externalIntent = externalNavigationIntent(url) + if (externalIntent == null) { + showLinkError(R.string.academic_link_invalid) + return + } + externalNavigationDialog = MaterialAlertDialogBuilder(this) + .setTitle(R.string.academic_external_link_title) + .setMessage(getString(R.string.academic_external_link_detail, url.take(240))) + .setPositiveButton(R.string.academic_external_link_open) { _, _ -> + try { + startActivity(externalIntent) + } catch (_: RuntimeException) { + openIntentFallbackOrShowError(view, externalIntent) + } + } + .setNegativeButton(R.string.cancel, null) + .setOnDismissListener { externalNavigationDialog = null } + .create().also { it.show() } + } + + private fun externalNavigationIntent(url: String): Intent? = runCatching { + val intent = if (navigationScheme(url) == "intent") { + Intent.parseUri(url, Intent.URI_INTENT_SCHEME) + } else { + Intent(Intent.ACTION_VIEW, Uri.parse(url)) + } + intent.addCategory(Intent.CATEGORY_BROWSABLE) + intent.component = null + intent.selector = null + intent + }.getOrNull() + + private fun openIntentFallbackOrShowError(view: WebView, intent: Intent) { + intent.getStringExtra("browser_fallback_url") + ?.takeIf(::isWebNavigationUrl) + ?.let(view::loadUrl) + ?: showLinkError(R.string.academic_link_unavailable) + } + + private fun showLinkError(message: Int) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } + + private fun navigationScheme(url: String?): String? = runCatching { + Uri.parse(url.orEmpty()).scheme?.lowercase(Locale.ROOT) + }.getOrNull() + + private fun isWebNavigationUrl(url: String?): Boolean = when (navigationScheme(url)) { + "http", "https" -> true + else -> false + } + + private fun webNavigationHost(url: String?): String? = + url?.takeIf(::isWebNavigationUrl)?.let { Uri.parse(it).host } + + private fun fetchCurrentSchedule() { + if (activeRequestToken != null) return + if (pageHadError) { + showFetchError(getString(R.string.academic_page_load_failed)) + return + } + if (!isWebNavigationUrl(binding.webView.url)) { + showFetchError(getString(R.string.academic_page_load_failed)) + return + } + val requestToken = UUID.randomUUID().toString() + activeRequestToken = requestToken + timeoutJob?.cancel() + timeoutJob = lifecycleScope.launch { + delay(REQUEST_TIMEOUT_MILLIS) + if (isActiveRequest(requestToken)) showFetchError(getString(R.string.academic_request_timeout)) + } + setImportStatus(true, getString(R.string.academic_identifying_term)) + if (school.system == AcademicSystem.QIANGZHI_HTML) { + captureGenericSchedule(requestToken) + return + } + if (school.system == AcademicSystem.ZHENGFANG_HTML) { + captureGenericZhengfang(requestToken) + return + } + if (school.system != AcademicSystem.WISEDU) { + captureGenericSchedule(requestToken) + return + } + if (school.isGeneric) { + captureWisedu(requestToken) + return + } + binding.webView.evaluateJavascript(ScriptHolder.termProbeScript()) { encodedPageText -> + if (!isActiveRequest(requestToken)) return@evaluateJavascript + val pageText = runCatching { + JSONTokener(encodedPageText).nextValue() as? String + }.getOrNull().orEmpty() + val termHint = normalizeWiseduTerm(pageText).orEmpty() + captureWisedu(requestToken, termHint) + } + } + + private fun captureQiangzhiSchedule(token: String) { + lifecycleScope.launch { + delay(MIN_IDENTIFYING_DISPLAY_MILLIS) + if (!isActiveRequest(token)) return@launch + setImportStatus(true, getString(R.string.academic_fetching_schedule)) + binding.webView.evaluateJavascript(QiangzhiCaptureScript.create(school)) { encoded -> + if (!isActiveRequest(token)) return@evaluateJavascript + val payload = runCatching { JSONTokener(encoded).nextValue() as? String }.getOrNull() + val error = runCatching { JSONObject(payload.orEmpty()).optString("error") }.getOrNull() + if (payload == null || error == null || error.isNotEmpty()) { + val message = when (error) { + "large" -> R.string.academic_response_too_large + "frame" -> R.string.academic_frame_unavailable + else -> if (school.isGeneric) R.string.academic_generic_no_table else R.string.academic_qiangzhi_no_table + } + val detail = getString(message) + val code = when (error) { + "large" -> AcademicImportErrorCode.PAYLOAD_TOO_LARGE + "frame" -> AcademicImportErrorCode.FRAME_BLOCKED + else -> AcademicImportErrorCode.CAPTURE_MISS + } + showFetchError(detail, payload, ImportFormatException(detail, code)) + } else receiveSchedule(token, payload) + } + } + } + + private fun captureGenericSchedule(token: String) { + lifecycleScope.launch { + delay(MIN_IDENTIFYING_DISPLAY_MILLIS) + if (!isActiveRequest(token)) return@launch + setImportStatus(true, getString(R.string.academic_fetching_schedule)) + // New ShuWei deployments: prefer the JSON already captured from the + // trusted /print-data response over the DOM snapshot. + if (school.system == AcademicSystem.SHUWEI) { + val stash = shuweiStash + if (stash != null) { + val payload = ShuweiPrintDataCapture.wrapPayload( + stash.first, stash.second, AcademicSchools.MAX_PAYLOAD_CHARS + ) + if (payload != null) { + receiveSchedule(token, payload) + return@launch + } + } + } + binding.webView.evaluateJavascript(AcademicCaptureScript.create(school)) { encoded -> + if (!isActiveRequest(token)) return@evaluateJavascript + val payload = runCatching { JSONTokener(encoded).nextValue() as? String }.getOrNull() + val error = runCatching { JSONObject(payload.orEmpty()).optString("error") }.getOrNull() + if (payload == null || error == null || error.isNotEmpty()) { + val message = when (error) { + "large" -> R.string.academic_response_too_large + "frame" -> R.string.academic_frame_unavailable + else -> R.string.academic_generic_no_table + } + val detail = getString(message) + val code = when (error) { + "large" -> AcademicImportErrorCode.PAYLOAD_TOO_LARGE + "frame" -> AcademicImportErrorCode.FRAME_BLOCKED + else -> AcademicImportErrorCode.CAPTURE_MISS + } + showFetchError(detail, payload, ImportFormatException(detail, code)) + } else receiveSchedule(token, payload) + } + } + } + + private fun captureGenericZhengfang(token: String) { + resultPollJob = lifecycleScope.launch { + delay(MIN_IDENTIFYING_DISPLAY_MILLIS) + if (!isActiveRequest(token)) return@launch + setImportStatus(true, getString(R.string.academic_fetching_schedule)) + binding.webView.evaluateJavascript(ScriptHolder.zhengfangFetchScript(token), null) + while (isActiveRequest(token)) { + delay(250L) + val encoded = suspendCancellableCoroutine { continuation -> + binding.webView.evaluateJavascript(ScriptHolder.pollResultScript(token)) { + if (continuation.isActive) continuation.resume(it) + } + } + if (!isActiveRequest(token)) return@launch + val snapshot = runCatching { + JSONObject(JSONTokener(encoded.orEmpty()).nextValue() as String) + }.getOrNull() ?: continue + if (snapshot.has("json")) { + resultPollJob = null + receiveSchedule(token, snapshot.getString("json")) + return@launch + } + if (snapshot.optString("stage") == "fallback" || snapshot.has("error")) { + resultPollJob = null + captureGenericSchedule(token) + return@launch + } + } + } + } + + private fun captureWisedu(token: String, termHint: String = "") { + resultPollJob = lifecycleScope.launch { + delay(MIN_IDENTIFYING_DISPLAY_MILLIS) + if (!isActiveRequest(token)) return@launch + val basePath = runCatching { + GenericAcademicImport.wiseduBasePath(binding.webView.url.orEmpty()) + }.getOrElse { + showFetchError(getString(R.string.academic_open_timetable_hint)) + return@launch + } + binding.webView.evaluateJavascript( + ScriptHolder.fetchScript(token, termHint, basePath), + null + ) + while (isActiveRequest(token)) { + delay(250L) + val encoded = suspendCancellableCoroutine { continuation -> + binding.webView.evaluateJavascript(ScriptHolder.pollResultScript(token)) { + if (continuation.isActive) continuation.resume(it) + } + } + if (!isActiveRequest(token)) return@launch + val snapshot = runCatching { + JSONObject(JSONTokener(encoded.orEmpty()).nextValue() as String) + }.getOrNull() ?: continue + if (snapshot.has("error")) { + showFetchError(snapshot.optString("error")) + return@launch + } + if (snapshot.has("json")) { + resultPollJob = null + receiveSchedule(token, snapshot.getString("json")) + return@launch + } + if (snapshot.optString("stage") == "schedule" && + binding.tvStatus.text.toString() != getString(R.string.academic_fetching_schedule)) { + setImportStatus(true, getString(R.string.academic_fetching_schedule)) + } + } + } + } + + private fun isActiveRequest(token: String?): Boolean = + !pageHadError && !isFinishing && !isDestroyed && token != null && token == activeRequestToken + + private fun invalidateRequest() { + val token = activeRequestToken + activeRequestToken = null + resultPollJob?.cancel() + resultPollJob = null + timeoutJob?.cancel() + timeoutJob = null + if (token != null && ::binding.isInitialized && school.isGeneric) { + binding.webView.evaluateJavascript(ScriptHolder.clearResultScript(token), null) + } + } + + private fun setImportStatus(loading: Boolean, status: String, success: Boolean = false) { + val statusChanged = binding.tvStatus.text?.toString() != status + binding.tvStatus.animate().cancel() + binding.tvStatus.text = status + if (statusChanged && binding.tvStatus.isLaidOut) { + binding.tvStatus.alpha = 0.3f + binding.tvStatus.translationY = 7f * resources.displayMetrics.density + binding.tvStatus.animate() + .alpha(1f) + .translationY(0f) + .setDuration(320L) + .setInterpolator(statusInterpolator) + .start() + } else { + binding.tvStatus.alpha = 1f + binding.tvStatus.translationY = 0f + } + binding.statusProgress.visibility = if (loading) View.VISIBLE else View.GONE + binding.statusIcon.visibility = if (loading) View.GONE else View.VISIBLE + binding.statusIcon.setImageResource(if (success) R.drawable.ic_check else R.drawable.ic_school) + binding.statusIcon.animate().cancel() + if (success) { + binding.statusIcon.apply { + alpha = 0.35f + scaleX = 0.68f + scaleY = 0.68f + rotation = -9f + animate() + .alpha(1f) + .scaleX(1f) + .scaleY(1f) + .rotation(0f) + .setDuration(480L) + .setInterpolator(OvershootInterpolator(1.45f)) + .start() + } + } else { + binding.statusIcon.alpha = 1f + binding.statusIcon.scaleX = 1f + binding.statusIcon.scaleY = 1f + binding.statusIcon.rotation = 0f + } + binding.btnFetchSchedule.isEnabled = !loading && !success + binding.btnOpenSchool.isEnabled = !loading && !success + binding.btnFetchSchedule.alpha = if (binding.btnFetchSchedule.isEnabled) 1f else 0.72f + } + + private fun showFetchError( + message: String, + payload: String? = null, + error: Throwable? = null + ) { + invalidateRequest() + setImportStatus(false, getString(R.string.academic_fetch_failed)) + val currentUrl = binding.webView.url + lifecycleScope.launch { + val diagnostic = withContext(Dispatchers.Default) { + AcademicImportDiagnostics.create( + appVersion = appVersionName(), + school = school, + payload = payload, + error = error ?: ImportFormatException(message), + currentUrl = currentUrl + ) + } + if (isFinishing || isDestroyed) return@launch + pendingDiagnostic = diagnostic + MaterialAlertDialogBuilder(this@AcademicWebImportActivity) + .setTitle(R.string.academic_fetch_failed) + .setMessage(message.take(240).ifBlank { getString(R.string.academic_fetch_failed_detail) }) + .setNeutralButton(R.string.academic_view_diagnostic) { _, _ -> + showDiagnosticPreview(diagnostic) + } + .setPositiveButton(R.string.ok, null) + .show() + } + } + + private fun showDiagnosticPreview(diagnostic: String) { + val padding = (20 * resources.displayMetrics.density).toInt() + val content = TextView(this).apply { + text = diagnostic + setTextIsSelectable(true) + typeface = Typeface.MONOSPACE + textSize = 12f + setPadding(padding, padding, padding, padding) + } + val scroll = ScrollView(this).apply { + addView(content, ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + )) + } + MaterialAlertDialogBuilder(this) + .setTitle(R.string.academic_diagnostic_preview_title) + .setMessage(R.string.academic_diagnostic_preview_detail) + .setView(scroll) + .setNegativeButton(R.string.close, null) + .setPositiveButton(R.string.academic_export_diagnostic) { _, _ -> + pendingDiagnostic = diagnostic + diagnosticExporter.launch("courseschedule-diagnostic-${school.id}.json") + } + .show() + } + + @Suppress("DEPRECATION") + private fun appVersionName(): String = runCatching { + packageManager.getPackageInfo(packageName, 0).versionName.orEmpty() + }.getOrDefault("unknown") + + private fun receiveSchedule(token: String, scheduleJson: String) { + if (!isActiveRequest(token)) return + if (scheduleJson.length > AcademicSchools.MAX_PAYLOAD_CHARS) { + showFetchError(getString(R.string.academic_response_too_large)) + return + } + setImportStatus(true, getString(R.string.academic_parsing_courses)) + lifecycleScope.launch { + val parsingStartedAt = SystemClock.elapsedRealtime() + val result = runCatching { + withContext(Dispatchers.Default) { + AcademicSchools.parse(school, scheduleJson, totalWeeks).courses + .map { it.courseName.trim() }.distinct().size + } + } + val remainingDisplayTime = MIN_PARSING_DISPLAY_MILLIS - + (SystemClock.elapsedRealtime() - parsingStartedAt) + if (remainingDisplayTime > 0L) delay(remainingDisplayTime) + if (!isActiveRequest(token)) return@launch + result.onSuccess { courseCount -> + setImportStatus(false, getString(R.string.academic_courses_ready, courseCount), success = true) + delay(SUCCESS_DISPLAY_MILLIS) + if (!isActiveRequest(token)) return@onSuccess + invalidateRequest() + setResult(Activity.RESULT_OK, schoolIntent(this@AcademicWebImportActivity, school) + .putExtra(EXTRA_SCHEDULE_JSON, scheduleJson)) + finish() + }.onFailure { error -> + showFetchError( + error.message ?: getString(R.string.academic_fetch_failed_detail), + payload = scheduleJson, + error = error + ) + } + } + } + + override fun onDestroy() { + invalidateRequest() + externalNavigationDialog?.dismiss() + sslErrorDialog?.dismiss() + shuweiStash = null + if (::binding.isInitialized) { + binding.webView.stopLoading() + binding.webView.destroy() + } + super.onDestroy() + } + + internal object ScriptHolder { + fun termProbeScript(): String = TERM_PROBE_SCRIPT + + fun zhengfangFetchScript(requestToken: String): String = ZHENGFANG_FETCH_SCRIPT + .replace("__REQUEST_TOKEN__", JSONObject.quote(requestToken)) + .replace("__BRIDGE__", SNAPSHOT_BRIDGE) + .replace("__LIMIT__", AcademicSchools.MAX_PAYLOAD_CHARS.toString()) + + fun fetchScript(requestToken: String, termHint: String, basePath: String = "/jwapp"): String = FETCH_SCRIPT + .replace("__REQUEST_TOKEN__", JSONObject.quote(requestToken)) + .replace("__TERM_HINT__", JSONObject.quote(termHint)) + .replace("__WISEDU_BASE__", JSONObject.quote(basePath)) + .replace("__BRIDGE__", SNAPSHOT_BRIDGE) + + fun clearResultScript(token: String): String = + "delete window[${JSONObject.quote("__courseSchedule_" + token)}];" + + fun pollResultScript(token: String): String = """ + (function () { + const key = ${JSONObject.quote("__courseSchedule_" + token)}; + const result = window[key]; + if (!result || typeof result !== 'object') return '{}'; + if (typeof result.error === 'string') { + delete window[key]; + return JSON.stringify({error: result.error.slice(0, 240)}); + } + if (typeof result.json === 'string') { + delete window[key]; + if (result.json.length > ${AcademicSchools.MAX_PAYLOAD_CHARS}) { + return JSON.stringify({error: '教务数据过大,请只查询一个学期后重试'}); + } + return JSON.stringify({json: result.json}); + } + const stage = typeof result.stage === 'string' ? result.stage.slice(0, 20) : 'term'; + return JSON.stringify({stage: stage}); + })(); + """.trimIndent() + + // This is a plain page-local object, NOT an Android JavascriptInterface. + private val SNAPSHOT_BRIDGE = """ + (function () { + const state = {stage: 'term'}; + window[resultKey] = state; + return { + onStage: function (token, stage) { if (token === requestToken) state.stage = stage; }, + onScheduleJson: function (token, json) { + if (token !== requestToken) return; + if (typeof json !== 'string' || json.length > ${AcademicSchools.MAX_PAYLOAD_CHARS}) { + state.error = '教务数据过大,请只查询一个学期后重试'; + } else state.json = json; + }, + onImportError: function (token, message) { + if (token === requestToken) state.error = String(message || '读取课表失败').slice(0, 240); + } + }; + })() + """.trimIndent() + + private val TERM_PROBE_SCRIPT = """ + (function () { + const values = []; + function collect(doc, depth) { + if (!doc || depth > 3) return; + doc.querySelectorAll( + 'select[name*="XNXQ"], select[id*="XNXQ"], input[name*="XNXQ"], input[id*="XNXQ"]' + ).forEach(function (element) { + if (element.value) values.push(String(element.value)); + if (element.options && element.selectedIndex >= 0) { + values.push(String(element.options[element.selectedIndex].text || '')); + } + }); + values.push(doc.body ? doc.body.innerText : ''); + doc.querySelectorAll('iframe').forEach(function (frame) { + try { collect(frame.contentDocument, depth + 1); } catch (_) {} + }); + } + collect(document, 0); + return values.join('\n').slice(0, 20000); + })(); + """.trimIndent() + + private val ZHENGFANG_FETCH_SCRIPT = """ + (function () { + 'use strict'; + const requestToken = __REQUEST_TOKEN__; + const resultKey = '__courseSchedule_' + requestToken; + const bridge = __BRIDGE__; + const limit = __LIMIT__; + const fields = [ + 'kcmc', 'kcm', 'courseName', 'xqj', 'xq', 'skxq', 'day', 'weekday', + 'jcs', 'jc', 'sksj', 'ksjc', 'skjc', 'startSection', 'jsjc', + 'endSection', 'cxjc', 'sectionCount', 'zcd', 'zc', 'skzc', 'weeks', + 'xm', 'jsxm', 'jsmc', 'teacher', 'cdmc', 'jxcdmc', 'jasmc', + 'classroom', 'room' + ]; + + function object(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : null; + } + function hasAny(value, names) { + return names.some(function (name) { return name in value; }); + } + function courseRow(value) { + return object(value) && + hasAny(value, ['kcmc', 'kcm', 'courseName']) && + hasAny(value, ['xqj', 'xq', 'skxq', 'day', 'weekday']) && + hasAny(value, ['jcs', 'jc', 'sksj', 'ksjc', 'skjc', 'startSection']) && + hasAny(value, ['zcd', 'zc', 'skzc', 'weeks']); + } + function findRows(root, depth, seen) { + if (root == null || depth > 10) return null; + if (typeof root === 'object') { + if (seen.has(root) || seen.size > 3000) return null; + seen.add(root); + } + if (Array.isArray(root)) { + if (root.some(courseRow)) return root.filter(courseRow).slice(0, 1500); + for (let index = 0; index < Math.min(root.length, 600); index++) { + const rows = findRows(root[index], depth + 1, seen); + if (rows) return rows; + } + } else if (object(root)) { + const keys = Object.keys(root).slice(0, 160); + for (const key of keys) { + const rows = findRows(root[key], depth + 1, seen); + if (rows) return rows; + } + } + return null; + } + function cleanRows(root) { + const rows = findRows(root, 0, new Set()); + if (!rows) return null; + return rows.map(function (row) { + const clean = {}; + fields.forEach(function (field) { + const value = row[field]; + if (typeof value === 'string' || typeof value === 'number') { + clean[field] = String(value).slice(0, + /^(?:zcd|zc|skzc|weeks)$/.test(field) ? 512 : 256); + } + }); + return clean; + }); + } + function decoded(value) { + if (typeof value !== 'string') return value; + const source = value.trim(); + if (!/^[\[{]/.test(source) || source.length > limit / 2) return null; + try { return JSON.parse(source); } catch (_) { return null; } + } + function controlValue(doc, name) { + const controls = doc.querySelectorAll('input,select'); + for (const control of controls) { + const id = String(control.id || '').toLowerCase(); + const field = String(control.name || '').toLowerCase(); + if (id === name || field === name) { + const value = String(control.value || '').trim(); + if (value) return value; + } + } + return ''; + } + function endpointFor(doc) { + const location = doc.defaultView.location; + const explicit = doc.querySelector( + '[action*="xskbcx_cxXskbcxIndex"],[href*="xskbcx_cxXskbcxIndex"]' + ); + if (explicit) { + const value = explicit.getAttribute('action') || explicit.getAttribute('href'); + try { + const url = new URL(value, location.href); + if (url.origin === location.origin) return url.origin + url.pathname; + } catch (_) {} + } + const match = location.pathname.match(/^(.*?)(?:\/(?:kbcx|xtgl|xsxxxggl|xkgl)\/)/i); + if (!match) return ''; + return location.origin + match[1] + '/kbcx/xskbcx_cxXskbcxIndex.html'; + } + function page(doc, depth) { + if (!doc || depth > 4) return null; + const win = doc.defaultView; + const candidates = []; + try { candidates.push(win.veInitDefaultJson); } catch (_) {} + try { candidates.push(win.__INITIAL_STATE__); } catch (_) {} + try { + const body = doc.body; + const raw = body && body.children.length <= 1 ? (body.textContent || '').trim() : ''; + if (raw.length >= 2 && raw.length <= limit / 2 && /^[\[{]/.test(raw)) { + candidates.push(raw); + } + } catch (_) {} + for (const candidate of candidates) { + const rows = cleanRows(decoded(candidate)); + if (rows) return {doc: doc, rows: rows}; + } + const year = controlValue(doc, 'xnm'); + const term = controlValue(doc, 'xqm'); + const endpoint = endpointFor(doc); + if (/^\d{4}$/.test(year) && /^\d{1,2}$/.test(term) && endpoint) { + return {doc: doc, year: year, term: term, endpoint: endpoint}; + } + for (const frame of doc.querySelectorAll('iframe,frame')) { + try { + const found = page(frame.contentDocument, depth + 1); + if (found) return found; + } catch (_) {} + } + return null; + } + function finish(found, rows) { + const location = found.doc.defaultView.location; + const payload = JSON.stringify({ + html: '', data: rows, term: found.year && found.term ? found.year + '-' + found.term : '', + sourceUrl: location.origin + location.pathname, profile: 'zhengfang' + }); + if (payload.length > limit) bridge.onStage(requestToken, 'fallback'); + else bridge.onScheduleJson(requestToken, payload); + } + async function run() { + const found = page(document, 0); + if (!found) { + bridge.onStage(requestToken, 'fallback'); + return; + } + if (found.rows) { + finish(found, found.rows); + return; + } + const controller = typeof AbortController === 'function' ? new AbortController() : null; + const timer = setTimeout(function () { if (controller) controller.abort(); }, 7000); + try { + const body = new URLSearchParams({ + xnm: found.year, xqm: found.term, kzlx: 'ck', + 'queryModel.showCount': '2000', 'queryModel.currentPage': '1', + 'queryModel.sortName': '', 'queryModel.sortOrder': 'asc' + }).toString(); + const response = await fetch( + found.endpoint + '?doType=query&gnmkdm=N2151', + { + method: 'POST', credentials: 'include', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: body, + signal: controller ? controller.signal : undefined + } + ); + const text = await response.text(); + const rows = response.ok && text.length <= limit / 2 + ? cleanRows(decoded(text)) : null; + if (rows) finish(found, rows); + else bridge.onStage(requestToken, 'fallback'); + } catch (_) { + bridge.onStage(requestToken, 'fallback'); + } finally { + clearTimeout(timer); + } + } + run(); + })(); + """.trimIndent() + + private val FETCH_SCRIPT = """ + (function () { + const requestToken = __REQUEST_TOKEN__; + const resultKey = '__courseSchedule_' + requestToken; + const bridge = __BRIDGE__; + const wiseduBase = __WISEDU_BASE__; + const wiseduRoot = wiseduBase.replace(/\/(?:jwapp|gsapp)$/, ''); + const jwappBase = wiseduRoot + '/jwapp'; + const gsappBase = wiseduRoot + '/gsapp'; + const termHint = __TERM_HINT__; + const formHeaders = { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + 'X-Requested-With': 'XMLHttpRequest' + }; + + async function readJson(url, options) { + const response = await fetch(url, Object.assign({ credentials: 'include' }, options || {})); + const text = await response.text(); + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + throw new Error('登录状态无效,请先完成学校网页登录后再读取课表'); + } + throw new Error('教务系统请求失败(' + response.status + ')'); + } + try { + return JSON.parse(text); + } catch (_) { + throw new Error('教务系统返回了登录页面,请先完成登录后再读取课表'); + } + } + + function normalizeTerm(value) { + const text = String(value || '').trim(); + let match = text.match(/(20\d{2})\s*[-_/]\s*(20\d{2})\s*[-_/]\s*([1-3])/); + if (match) return match[1] + '-' + match[2] + '-' + match[3]; + match = text.match( + /(20\d{2})\s*[-~~—–-至/]\s*(20\d{2})\s*学年[\s\S]{0,16}?(秋季|春季|夏季|第一|第二|第三|1|2|3)\s*学期/ + ); + if (match) { + const semester = /^(秋季|第一|1)$/.test(match[3]) ? '1' : + (/^(春季|第二|2)$/.test(match[3]) ? '2' : '3'); + return match[1] + '-' + match[2] + '-' + semester; + } + match = text.match(/(?:^|\D)(20\d{2})(20\d{2})([1-3])(?:\D|$)/); + return match ? match[1] + '-' + match[2] + '-' + match[3] : ''; + } + + function selectedTermFromPage() { + const selectors = [ + 'select[name="XNXQDM"]', + '#XNXQDM', + '[data-name="XNXQDM"] select' + ]; + for (const selector of selectors) { + const element = document.querySelector(selector); + if (!element) continue; + const value = element.value || (element.options && element.options[element.selectedIndex] + ? element.options[element.selectedIndex].value + : ''); + const normalized = normalizeTerm(value); + if (normalized) return normalized; + } + return normalizeTerm(document.body ? document.body.innerText : ''); + } + + function termFromResponse(data) { + const candidates = []; + function walk(value, depth) { + if (depth > 6 || value == null) return; + if (Array.isArray(value)) { + value.forEach(function (item) { walk(item, depth + 1); }); + return; + } + if (typeof value !== 'object') { + const normalized = normalizeTerm(value); + if (normalized) candidates.push(normalized); + return; + } + Object.keys(value).forEach(function (key) { + const child = value[key]; + walk(child, depth + 1); + }); + } + walk(data, 0); + return candidates[0] || ''; + } + + function containsCourseRows(value, depth) { + if (depth > 8 || value == null) return false; + if (Array.isArray(value)) { + return value.some(function (item) { + return item && typeof item === 'object' && + (('KCM' in item && 'SKXQ' in item && 'KSJC' in item) || + ('KCMC' in item && 'PKSJDD' in item) || + ('kcmc' in item && 'xqj' in item && 'djj' in item && 'qmz' in item)); + }) || value.some(function (item) { return containsCourseRows(item, depth + 1); }); + } + if (typeof value !== 'object') return false; + return Object.keys(value).some(function (key) { + return containsCourseRows(value[key], depth + 1); + }); + } + + async function readSchedule(term) { + const bodies = []; + if (term) { + bodies.push( + 'XNXQDM=' + encodeURIComponent(term) + '&pageSize=200&pageNumber=1' + ); + } + bodies.push('pageSize=200&pageNumber=1'); + const requests = []; + const termQuery = term ? '?XNXQDM=' + encodeURIComponent(term) : ''; + // Keep the verified SWPU route first. Only continue to the + // other common Wisedu variants when it returns no courses. + bodies.forEach(function (body) { + requests.push({ + url: jwappBase + '/sys/wdkb/modules/xskcb/xskcb.do', + options: { method: 'POST', headers: formHeaders, body: body } + }); + }); + requests.push({ + url: jwappBase + '/sys/xkjglapp/modules/xskcb/xsjxrwcx.do' + termQuery, + options: { method: 'GET' } + }); + requests.push({ + url: gsappBase + '/sys/wdkbapp/modules/xskcb/xsjxrwcx.do' + termQuery, + options: { method: 'GET' } + }); + requests.push({ + url: jwappBase + '/sys/homeapp/api/home/student/getMyScheduleDetail.do', + options: { + method: 'POST', headers: formHeaders, + body: 'termCode=' + encodeURIComponent(term || '') + '&campusCode=&type=term' + } + }); + let lastResponse = null; + let lastError = null; + for (const request of requests) { + try { + lastResponse = await readJson(request.url, request.options); + if (containsCourseRows(lastResponse, 0)) return lastResponse; + } catch (error) { + lastError = error; + } + } + if (lastResponse == null && lastError) throw lastError; + return lastResponse; + } + + async function run() { + try { + try { + const entry = /\/gsapp$/.test(wiseduBase) + ? '/sys/wdkbapp/*default/index.do' + : '/sys/wdkb/*default/index.do'; + await fetch(wiseduBase + entry, { credentials: 'include' }); + } catch (_) {} + + let term = normalizeTerm(termHint) || selectedTermFromPage(); + if (!term) { + try { + const termData = await readJson( + jwappBase + '/sys/wdkb/modules/jshkcb/dqxnxq.do', + { method: 'POST', headers: formHeaders, body: '' } + ); + term = termFromResponse(termData); + } catch (_) {} + } + + bridge.onStage(requestToken, 'schedule'); + await new Promise(function (resolve) { setTimeout(resolve, 360); }); + const schedule = await readSchedule(term); + if (!containsCourseRows(schedule, 0)) { + throw new Error( + term + ? '已识别学期 ' + term + ',但教务接口没有返回课程,请确认学生课表已有数据' + : '无法识别当前学期,且教务接口没有返回默认学期课表' + ); + } + bridge.onScheduleJson( + requestToken, + JSON.stringify({ term: term, payload: schedule }) + ); + } catch (error) { + let message = error && error.message ? error.message : String(error); + if (/Failed to fetch|Load failed|NetworkError/i.test(message)) { + message = '教务请求未完成,请确认已登录并进入课表页面后重试'; + } + bridge.onImportError( + requestToken, + message + ); + } + } + + run(); + })(); + """.trimIndent() + } + +} diff --git a/app/src/main/java/com/courseschedule/ui/importdata/EamsScheduleParser.kt b/app/src/main/java/com/courseschedule/ui/importdata/EamsScheduleParser.kt new file mode 100644 index 0000000..6ad9934 --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/EamsScheduleParser.kt @@ -0,0 +1,152 @@ +package com.courseschedule.ui.importdata + +import com.courseschedule.data.entity.Course +import com.google.gson.JsonElement +import com.google.gson.JsonObject + +/** Strict parser for the EAMS `table0 + unitCount` page-global contract. */ +internal class EamsScheduleParser { + fun parse(data: JsonElement?): ParsedImport { + val holder = data?.let { findHolder(it, 0, mutableSetOf()) } + ?: throw ImportFormatException( + "未找到 EAMS table0 课表数据", + AcademicImportErrorCode.NO_TIMETABLE + ) + val unitCount = holder.primitiveInt("unitCount") + ?: throw ImportFormatException("EAMS 节次数缺失", AcademicImportErrorCode.MISSING_SECTION) + if (unitCount !in 1..30) { + throw ImportFormatException("EAMS 节次数异常", AcademicImportErrorCode.MISSING_SECTION) + } + val activities = holder.getAsJsonArray("activities") + ?: throw ImportFormatException("EAMS 活动列表缺失", AcademicImportErrorCode.NO_TIMETABLE) + if (activities.size() > unitCount * 7) { + throw ImportFormatException("EAMS 课表网格超出七天范围", AcademicImportErrorCode.PARTIAL_PARSE) + } + + data class Meeting( + val name: String, + val teacher: String, + val room: String, + val day: Int, + val section: Int, + val weeks: List + ) + + var courseItems = 0 + val meetings = activities.flatMapIndexed { index, cell -> + val values = cell.takeIf(JsonElement::isJsonArray)?.asJsonArray + ?: throw ImportFormatException( + "EAMS 单元格结构不完整", + AcademicImportErrorCode.PARTIAL_PARSE + ) + values.map { value -> + courseItems++ + if (courseItems > 1_500) { + throw ImportFormatException("EAMS 课程数量异常", AcademicImportErrorCode.PAYLOAD_TOO_LARGE) + } + val item = value.takeIf(JsonElement::isJsonObject)?.asJsonObject + ?: throw ImportFormatException( + "EAMS 课程结构不完整", + AcademicImportErrorCode.PARTIAL_PARSE + ) + val name = item.primitiveString("courseName").trim() + if (name.isBlank()) { + throw ImportFormatException("EAMS 课程名称缺失", AcademicImportErrorCode.PARTIAL_PARSE) + } + Meeting( + name = name, + teacher = item.primitiveString("teacherName").trim(), + room = item.primitiveString("roomName").trim(), + day = index / unitCount + 1, + section = index % unitCount + 1, + weeks = parseWeekBitmap(item.primitiveString("vaildWeeks")) + ) + } + } + if (meetings.isEmpty()) { + throw ImportFormatException("当前 EAMS 页面没有课程", AcademicImportErrorCode.NO_TIMETABLE) + } + + val courses = meetings.groupBy { + listOf(it.name, it.teacher, it.room, it.day.toString(), it.weeks.joinToString(",")) + }.values.flatMap { sameCourse -> + val first = sameCourse.first() + val sectionRuns = contiguousRuns(sameCourse.map { it.section }.distinct()) + sectionRuns.flatMap { sections -> + compressImportWeeks(first.weeks).map { weeks -> + Course( + courseName = first.name, + teacher = first.teacher, + classroom = first.room, + dayOfWeek = first.day, + startSection = sections.first, + endSection = sections.last, + startWeek = weeks.start, + endWeek = weeks.end, + weekType = weeks.weekType, + colorIndex = Math.floorMod(first.name.hashCode(), 16) + ) + } + } + } + return ParsedImport(courses.distinctBy(::courseKey), sourceLabel = "EAMS · table0") + } + + private fun parseWeekBitmap(raw: String): List { + var bitmap = raw.trim() + if (!bitmap.matches(Regex("[01]{2,53}"))) { + throw ImportFormatException("EAMS 周次位图缺失或无效", AcademicImportErrorCode.MISSING_WEEK) + } + // EAMS spells this field `vaildWeeks`; index zero is a sentinel. + bitmap = bitmap.drop(1) + if (bitmap.length > 52) { + throw ImportFormatException("EAMS 周次超出范围", AcademicImportErrorCode.MISSING_WEEK) + } + return bitmap.mapIndexedNotNull { index, bit -> (index + 1).takeIf { bit == '1' } } + .ifEmpty { + throw ImportFormatException("EAMS 周次为空", AcademicImportErrorCode.MISSING_WEEK) + } + } + + private fun contiguousRuns(values: List): List { + val sorted = values.distinct().sorted() + val result = mutableListOf() + for (value in sorted) { + val last = result.lastOrNull() + if (last != null && last.last + 1 == value) result[result.lastIndex] = last.first..value + else result += value..value + } + return result + } + + private fun findHolder(value: JsonElement, depth: Int, seen: MutableSet): JsonObject? { + if (depth > 10 || seen.size > 3_000 || !seen.add(value)) return null + if (value.isJsonObject) { + val objectValue = value.asJsonObject + if (objectValue.get("activities")?.isJsonArray == true && objectValue.has("unitCount")) { + return objectValue + } + objectValue.entrySet().take(160).forEach { (_, child) -> + findHolder(child, depth + 1, seen)?.let { return it } + } + } else if (value.isJsonArray) { + value.asJsonArray.take(600).forEach { child -> + findHolder(child, depth + 1, seen)?.let { return it } + } + } + return null + } + + private fun JsonObject.primitiveString(key: String): String = get(key) + ?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString } + ?.asString.orEmpty() + + private fun JsonObject.primitiveInt(key: String): Int? = get(key) + ?.takeIf(JsonElement::isJsonPrimitive) + ?.asString?.toIntOrNull() + + private fun courseKey(course: Course): List = listOf( + course.courseName, course.teacher, course.classroom, course.dayOfWeek, + course.startSection, course.endSection, course.startWeek, course.endWeek, course.weekType + ) +} diff --git a/app/src/main/java/com/courseschedule/ui/importdata/FileImporter.kt b/app/src/main/java/com/courseschedule/ui/importdata/FileImporter.kt index ef5dfee..f629e8b 100644 --- a/app/src/main/java/com/courseschedule/ui/importdata/FileImporter.kt +++ b/app/src/main/java/com/courseschedule/ui/importdata/FileImporter.kt @@ -19,15 +19,17 @@ class FileImporter( if (fileName.endsWith(".xls")) { throw ImportFormatException("暂不支持旧版 .xls,请在 Excel 中另存为 .xlsx 后导入") } + val parser = ImportParser(totalWeeks) + if (fileName.endsWith(".html") || fileName.endsWith(".htm") || "html" in mimeType) { + val input = context.contentResolver.openInputStream(uri) + ?: throw ImportFormatException("无法读取所选文件") + return input.use { parser.parseHtml(it) } + } val text = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() } ?: throw ImportFormatException("无法读取所选文件") - val parser = ImportParser(totalWeeks) return when { fileName.endsWith(".json") || "json" in mimeType -> parser.parseJson(text) fileName.endsWith(".csv") || "csv" in mimeType -> parser.parseCsv(text) - fileName.endsWith(".html") || fileName.endsWith(".htm") || "html" in mimeType -> { - parser.parseHtml(text) - } else -> parser.parseText(text) } } diff --git a/app/src/main/java/com/courseschedule/ui/importdata/GdeiScheduleParser.kt b/app/src/main/java/com/courseschedule/ui/importdata/GdeiScheduleParser.kt new file mode 100644 index 0000000..5ef9223 --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/GdeiScheduleParser.kt @@ -0,0 +1,82 @@ +package com.courseschedule.ui.importdata + +import com.courseschedule.data.entity.Course +import org.jsoup.Jsoup +import org.jsoup.nodes.Element + +/** Parses GDEI's nested course blocks inside a weekday table. */ +internal class GdeiScheduleParser(private val totalWeeks: Int) { + fun parse(html: String): ParsedImport { + val document = Jsoup.parse(html) + val body = document.select("tbody").firstOrNull { candidate -> + candidate.select("tr").any { row -> + directCells(row).firstOrNull()?.text()?.let(SECTION::containsMatchIn) == true + } + } ?: throw ImportFormatException("未找到广二师课表", AcademicImportErrorCode.NO_TIMETABLE) + val courses = mutableListOf() + var unreadable = 0 + body.select("tr").take(30).forEach { row -> + val cells = directCells(row) + val fallback = cells.firstOrNull()?.text()?.let(::parseSections) ?: return@forEach + cells.drop(1).take(7).forEachIndexed { dayIndex, cell -> + cell.children().forEach blockLoop@{ wrapper -> + val fields = wrapper.children().toList() + if (fields.isEmpty() || fields.all { it.text().isBlank() }) return@blockLoop + if (fields.size < 5) { + unreadable++ + return@blockLoop + } + val name = fields[0].text().trim() + val spans = fields[1].select("span").map(Element::text) + val weeksText = spans.firstOrNull().orEmpty() + val sections = spans.getOrNull(1)?.let(::parseSections) ?: fallback + if (name.isBlank() || weeksText.isBlank()) { + unreadable++ + return@blockLoop + } + runCatching { + val weeks = QiangzhiScheduleParser(totalWeeks).parseWeeks(weeksText.replace(';', ',')) + compressImportWeeks(weeks).map { range -> + Course( + courseName = name, + teacher = fields[4].text().trim(), + classroom = fields[3].text().substringBefore('(').trim(), + dayOfWeek = dayIndex + 1, + startSection = sections.first, + endSection = sections.last, + startWeek = range.start, + endWeek = range.end, + weekType = range.weekType, + colorIndex = Math.floorMod(name.hashCode(), 16) + ) + } + }.onSuccess(courses::addAll).onFailure { unreadable++ } + } + } + } + if (unreadable > 0) throw ImportFormatException( + "有 $unreadable 个广二师课程块字段不完整,本次未导入", + AcademicImportErrorCode.PARTIAL_PARSE + ) + if (courses.isEmpty()) throw ImportFormatException( + "广二师课表页面没有课程", + AcademicImportErrorCode.NO_TIMETABLE + ) + return ParsedImport(courses.distinct(), sourceLabel = "广二师教务 · HTML") + } + + private fun parseSections(value: String): IntRange? { + val match = SECTION.find(value) ?: return null + val start = match.groupValues[1].toInt() + val end = match.groupValues[2].toIntOrNull() ?: start + return (start..end).takeIf { start in 1..30 && end in start..30 } + } + + private fun directCells(row: Element): List = row.children().toList().filter { + it.tagName() == "td" || it.tagName() == "th" + } + + companion object { + private val SECTION = Regex("第?\\s*(\\d{1,2})(?:\\s*[-,,~~—–-至]\\s*(\\d{1,2}))?\\s*节") + } +} diff --git a/app/src/main/java/com/courseschedule/ui/importdata/GenericAcademicImport.kt b/app/src/main/java/com/courseschedule/ui/importdata/GenericAcademicImport.kt new file mode 100644 index 0000000..acf6348 --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/GenericAcademicImport.kt @@ -0,0 +1,215 @@ +package com.courseschedule.ui.importdata + +import java.util.Locale +import java.net.URI + +internal data class GenericAcademicProfile( + val id: String, + val label: String, + val system: AcademicSystem, + val instructions: String +) + +/** User-confirmed addresses are scoped to one import session, never added to the school registry. */ +internal object GenericAcademicImport { + const val SCHOOL_ID = "generic" + val profiles = listOf( + GenericAcademicProfile("wisedu", "金智教务(jwapp / gsapp)", AcademicSystem.WISEDU, + "进入信息查询 → 学生个人课表,并切换到学期课表后读取。"), + GenericAcademicProfile("aic", "AIC 教务", AcademicSystem.AIC_HTML, + "进入学期课表;不要停留在只显示一周的周课表。"), + GenericAcademicProfile("urp_new", "URP 新版系统", AcademicSystem.URP_NEW, + "进入本学期课表,等课程完全加载后读取。"), + GenericAcademicProfile("urp", "URP 系统", AcademicSystem.URP_HTML, + "进入本学期课表;当前页面必须显示课程、星期、节次和周次。"), + GenericAcademicProfile("vatuu", "为途教务", AcademicSystem.VATUU_HTML, + "打开不显示学分的完整学期课表后读取。"), + GenericAcademicProfile("chengfang", "乘方教务", AcademicSystem.CHENGFANG, + "进入课表查询 → 我的课表,周次选择全部并查询。"), + GenericAcademicProfile("umooc", "优慕课在线", AcademicSystem.UMOOC_HTML, + "选择按小节显示的课表(第 1、2 节分开),再读取。"), + GenericAcademicProfile("xbell", "凌展教务", AcademicSystem.XBELL, + "进入包含完整周次与节次的个人课表,等页面加载完成后读取。"), + GenericAcademicProfile("eams", "EAMS 教务", AcademicSystem.EAMS, + "进入完整学期课表,等课程加载完成后读取。"), + GenericAcademicProfile("south_soft", "南软教务", AcademicSystem.SOUTH_SOFT_HTML, + "进入培养管理 → 学生课表查询,选择学期并查询。"), + GenericAcademicProfile("yilian", "奕联教务", AcademicSystem.YILIAN_HTML, + "进入个人课表,确认页面显示周次、节次、教师和地点。"), + GenericAcademicProfile("qiangzhi", "强智教务", AcademicSystem.QIANGZHI_HTML, + "进入我的课表 → 学期理论课表并查询完整学期。"), + GenericAcademicProfile("topology", "拓扑教务", AcademicSystem.STRUCTURED_HTML, + "打开包含课程、星期、节次和周次列的完整课表。"), + GenericAcademicProfile("structured", "通用 HTML 课表", AcademicSystem.STRUCTURED_HTML, + "打开包含课程、星期、节次和周次的完整课表;页面必须显示这些字段。"), + GenericAcademicProfile("shuwei_new", "新树维教务", AcademicSystem.SHUWEI, + "进入我的课表,等课表卡片全部加载后读取。"), + GenericAcademicProfile("kingosoft_new", "新青果教学安排表", AcademicSystem.KINGOSOFT_HTML, + "进入主控 → 教学安排,或班级课表 → 格式二,点击检索后读取。"), + GenericAcademicProfile("qiangzhi_legacy", "旧强智教务", AcademicSystem.QIANGZHI_HTML, + "进入学期理论课表;页面中必须能看到每门课的周次和节次。"), + GenericAcademicProfile("shuwei_mobile", "树维手机导入", AcademicSystem.SHUWEI, + "在手机课表页面等待课程加载完成后读取。"), + GenericAcademicProfile("shuwei", "树维教务", AcademicSystem.SHUWEI, + "进入我的课表并等待完整学期数据加载。"), + GenericAcademicProfile("shuwei_easy", "树维教务(简易导入)", AcademicSystem.SHUWEI, + "进入我的课表;若网页只显示当前周,请先切换到完整学期。"), + GenericAcademicProfile("zhengfang", "正方教务", AcademicSystem.ZHENGFANG_HTML, + "打开个人课表,并在网页设置中显示上课时间、教师和教室。"), + GenericAcademicProfile("chaoxing_share", "超星分享课表导入", AcademicSystem.CHAOXING_SHARE, + "打开无需登录即可查看的超星课表分享链接后读取。"), + GenericAcademicProfile("chaoxing", "超星教务", AcademicSystem.CHAOXING, + "登录超星教务并进入完整课表页面后读取。"), + GenericAcademicProfile("kingosoft_selected", "青果教务(正选结果导入)", AcademicSystem.KINGOSOFT_HTML, + "进入网上选课 → 正选结果,确认有课程数据后读取。") + ) + val systems = profiles.map(GenericAcademicProfile::system).distinct() + + fun profile(id: String?): GenericAcademicProfile? = profiles.firstOrNull { it.id == id } + + fun create(profile: GenericAcademicProfile, address: String): AcademicSchool = + create(profile.system, address, profile.id) + + fun create(system: AcademicSystem, address: String): AcademicSchool { + return create(system, address, profiles.firstOrNull { it.system == system }?.id) + } + + fun create(profileId: String, address: String): AcademicSchool { + val profile = profile(profileId) ?: throw ImportFormatException("不支持的教务系统类型") + return create(profile.system, address, profile.id) + } + + /** Bundled catalog entries may use a reviewed HTTP host or a legacy non-default port. */ + fun createCatalog( + profile: GenericAcademicProfile, + address: String, + allowCleartext: Boolean, + authenticationUrls: List = emptyList(), + timetableUrls: List = emptyList(), + adapterId: String = AcademicAdapterRegistry.defaultAdapterId(profile.id), + loginUrls: List = emptyList() + ): AcademicSchool { + val school = create(profile.system, address, profile.id, allowCleartext, allowNonDefaultPort = true) + val authenticationUris = authenticationUrls.map { + checkedAddress(it, allowCleartext, allowNonDefaultPort = true) + } + val timetableUris = timetableUrls.map { + checkedAddress(it, allowCleartext, allowNonDefaultPort = true) + } + val timetableScopes = timetableUris.map { + academicUrlScope(it.toString(), allowCleartext, allowNonDefaultPort = true) + ?: throw ImportFormatException("课表读取地址范围无效") + } + val loginUris = loginUrls.map { + checkedAddress(it, allowCleartext, allowNonDefaultPort = true) + } + val loginScopes = loginUris.map { + academicUrlScope(it.toString(), allowCleartext, allowNonDefaultPort = true) + ?: throw ImportFormatException("登录地址范围无效") + } + if (!AcademicAdapterRegistry.isCompatible(adapterId, profile.system)) { + throw ImportFormatException("不支持的本地解析适配器") + } + return school.copy( + trustedHosts = school.trustedHosts + + authenticationUris.map { it.host.lowercase(Locale.ROOT) } + + timetableUris.map { it.host.lowercase(Locale.ROOT) } + + loginUris.map { it.host.lowercase(Locale.ROOT) }, + authenticationPrefixes = authenticationUris.map { + academicUrlScope(it.toString(), allowCleartext, allowNonDefaultPort = true) + ?: throw ImportFormatException("认证地址范围无效") + }, + timetablePrefixes = timetableScopes.ifEmpty { school.timetablePrefixes }, + loginPrefixes = (school.loginPrefixes + loginScopes).distinct(), + adapterId = adapterId.ifBlank { AcademicAdapterRegistry.defaultAdapterId(profile.id) }, + cleartextHosts = school.cleartextHosts + (authenticationUris + timetableUris + loginUris) + .filter { it.scheme.equals("http", ignoreCase = true) } + .map { it.host.lowercase(Locale.ROOT) } + ) + } + + private fun create( + system: AcademicSystem, + address: String, + profileId: String?, + allowCleartext: Boolean = false, + allowNonDefaultPort: Boolean = false + ): AcademicSchool { + val uri = checkedAddress(address, allowCleartext, allowNonDefaultPort) + val host = uri.host.lowercase(Locale.ROOT) + val scope = academicUrlScope(uri.toString(), allowCleartext, allowNonDefaultPort) + ?: throw ImportFormatException("无法确认教务地址的访问范围") + val origin = "${uri.scheme.lowercase(Locale.ROOT)}://${academicAuthority(uri)}/" + if (host.contains("vpn") && scope == origin) { + throw ImportFormatException("请粘贴 WebVPN 中具体教务资源的地址,而不是 VPN 门户登录地址") + } + // Do not retain pasted tickets, query parameters or fragments in Activity extras/state. + val cleanPath = uri.rawPath.orEmpty() + .replace(Regex(";jsessionid=[^/]*", RegexOption.IGNORE_CASE), "") + .ifBlank { "/" } + val cleanAddress = "${uri.scheme.lowercase(Locale.ROOT)}://${academicAuthority(uri)}" + + cleanPath + val timetableScopes = buildList { + add(scope) + if (uri.scheme.equals("http", ignoreCase = true)) { + add("https://$host/") + uri.port.takeIf { it != -1 && it != 80 }?.let { add("https://$host:$it/") } + } + }.distinct() + return AcademicSchool( + id = SCHOOL_ID, name = host, system = system, loginUrl = cleanAddress, + trustedHosts = setOf(host), verified = false, loginPrefixes = listOf(scope), + timetablePrefixes = timetableScopes, + genericProfileId = profileId, + adapterId = AcademicAdapterRegistry.defaultAdapterId(profileId), + allowCleartext = allowCleartext, + cleartextHosts = if (allowCleartext) setOf(host) else emptySet(), + allowNonDefaultPort = allowNonDefaultPort + ) + } + + private fun checkedAddress( + address: String, + allowCleartext: Boolean = false, + allowNonDefaultPort: Boolean = false + ): URI { + val entered = address.trim().let { if ("://" in it) it else "https://$it" } + val uri = academicWebUri(entered, allowCleartext, allowNonDefaultPort) + ?: throw ImportFormatException("请输入学校的 HTTPS 网址,不支持明文 HTTP、特殊端口、IP 地址或含账号的地址;也可使用 HTML 文件导入") + val host = uri.host.lowercase(Locale.ROOT) + if ('.' !in host || host.endsWith('.') || host.matches(Regex("[0-9.]+")) || + ':' in host || host.endsWith(".local") || host.endsWith(".localhost")) { + throw ImportFormatException("请填写学校官方域名,不能使用本机或 IP 地址") + } + return uri + } + + fun authenticationScope(school: AcademicSchool, url: String): String? { + val uri = runCatching { checkedAddress(url) }.getOrNull() ?: return null + return academicUrlScope(uri.toString()).takeUnless { school.allowsNavigation(url) } + } + + fun allowAuthentication(school: AcademicSchool, url: String): AcademicSchool { + val scope = authenticationScope(school, url) + ?: throw ImportFormatException("无法授权此认证地址") + val host = academicHttpsUri(url)!!.host.lowercase(Locale.ROOT) + return school.copy( + trustedHosts = school.trustedHosts + host, + authenticationPrefixes = school.authenticationPrefixes + scope + ) + } + + fun wiseduBasePath(url: String): String { + val uri = academicWebUri(url, allowCleartext = true, allowNonDefaultPort = true) + ?: throw ImportFormatException("教务网址无效") + val path = uri.rawPath.orEmpty() + val gsapp = Regex("/gsapp(?:/|$)").find(path) + if (gsapp != null) return path.substring(0, gsapp.range.first) + "/gsapp" + val jwapp = Regex("/jwapp(?:/|$)").find(path) + if (jwapp != null) return path.substring(0, jwapp.range.first) + "/jwapp" + val scope = academicUrlScope(url, allowCleartext = true, allowNonDefaultPort = true) + ?: throw ImportFormatException("教务网址无效") + return academicWebUri(scope, allowCleartext = true, allowNonDefaultPort = true)!! + .rawPath.trimEnd('/') + "/jwapp" + } +} diff --git a/app/src/main/java/com/courseschedule/ui/importdata/GenericAcademicScheduleParser.kt b/app/src/main/java/com/courseschedule/ui/importdata/GenericAcademicScheduleParser.kt new file mode 100644 index 0000000..1e26ca5 --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/GenericAcademicScheduleParser.kt @@ -0,0 +1,625 @@ +package com.courseschedule.ui.importdata + +import com.courseschedule.data.entity.Course +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element + +/** + * Strict, local parsers for common academic-system payloads. Every generated course must carry + * an explicit weekday, section range and week set; an unrecognised row fails instead of guessing. + */ +internal class GenericAcademicScheduleParser(private val totalWeeks: Int) { + + fun parse(system: AcademicSystem, html: String, data: JsonElement?): ParsedImport { + val courses = when (system) { + AcademicSystem.URP_NEW -> parseNewUrp(requireData(data, "新版 URP 课表数据")) + AcademicSystem.CHENGFANG -> parseChengfang(data, html) + AcademicSystem.EAMS -> EamsScheduleParser().parse(data).courses + AcademicSystem.SHUWEI -> parseShuwei(requireData(data, "树维课表数据")) + AcademicSystem.XBELL -> parseXbell(requireData(data, "凌展课表数据")) + AcademicSystem.CHAOXING -> parseChaoxing(requireData(data, "超星教务课表数据")) + AcademicSystem.CHAOXING_SHARE -> parseChaoxingShare(requireData(data, "超星分享课表数据")) + AcademicSystem.AIC_HTML -> parseAic(html) + AcademicSystem.ZHENGFANG_HTML -> parseZhengfang(data, html) + AcademicSystem.URP_HTML, + AcademicSystem.VATUU_HTML, + AcademicSystem.UMOOC_HTML, + AcademicSystem.SOUTH_SOFT_HTML, + AcademicSystem.YILIAN_HTML, + AcademicSystem.KINGOSOFT_HTML, + AcademicSystem.STRUCTURED_HTML -> parseHtml(system, html) + AcademicSystem.WISEDU, + AcademicSystem.QIANGZHI_HTML -> throw ImportFormatException("教务解析器选择错误") + } + if (courses.isEmpty()) throw ImportFormatException("当前页面没有识别到可导入的课程") + return ParsedImport(courses.distinctBy(::courseKey), sourceLabel = "通用教务") + } + + private fun parseNewUrp(root: JsonElement): List { + val objects = collectObjects(root) + val dateCourses = objects.filter { it.has("courseName") && it.has("timeAndPlaceList") } + .flatMap { course -> + val name = course.string("courseName") + val teacher = course.string("attendClassTeacher") + course.array("timeAndPlaceList").flatMap { value -> + val time = value.asObjectOrNull() + ?: throw ImportFormatException("新版 URP 上课时间格式不完整") + val start = time.int("classSessions") + val count = time.int("continuingSession") + if (start == null || count == null || count < 1) { + throw ImportFormatException("新版 URP 课程节次缺失") + } + buildCourses( + name = name, + teacher = teacher, + room = listOf(time.string("campusName"), time.string("teachingBuildingName"), + time.string("classroomName")).joinToString("").trim(), + day = time.int("classDay"), + sectionRuns = listOf(start..(start + count - 1)), + weeks = bitmapWeeks(time.string("classWeek")) + ) + } + } + if (dateCourses.isNotEmpty()) return dateCourses + + val classList = objects.filter { it.has("kcm") && it.get("id")?.isJsonObject == true } + .flatMap { item -> + val id = item.getAsJsonObject("id") + val start = id.int("skjc") + ?: throw ImportFormatException("新版 URP 课程节次缺失") + val count = item.int("cxjc") + ?: throw ImportFormatException("新版 URP 持续节次缺失") + buildCourses( + name = item.string("kcm"), + teacher = item.string("jsm"), + room = item.string("jxlm") + item.string("jasm"), + day = id.int("skxq"), + sectionRuns = listOf(start..(start + count - 1)), + weeks = bitmapWeeks(id.string("skzc")) + ) + } + if (classList.isEmpty()) throw ImportFormatException("未找到新版 URP 的课程列表,请进入本学期课表并等待加载完成") + return classList + } + + private fun parseChengfang(data: JsonElement?, html: String): List { + val root = data ?: extractJavascriptArray(html, "kbxx") + ?: throw ImportFormatException("未找到乘方课表数据,请选择全部周次并点击查询课表") + val rows = collectObjects(root).filter { + it.has("kcmc") && it.has("xq") && it.has("jcdm2") && it.has("zcs") + } + if (rows.isEmpty()) throw ImportFormatException("乘方课表字段不完整") + return rows.flatMap { row -> + val sections = explicitNumbers(row.string("jcdm2"), 30) + buildCourses( + name = row.string("kcmc"), teacher = row.string("teaxms"), + room = row.string("jxcdmcs"), day = row.int("xq"), + sectionRuns = contiguousRuns(sections), weeks = explicitWeeks(row.string("zcs")) + ) + } + } + + private fun parseShuwei(root: JsonElement): List { + val container = collectObjects(root).firstOrNull { it.has("activities") } + ?: throw ImportFormatException("未找到树维课表活动数据,请在“我的课表”等待加载完成") + val activities = container.array("activities") + val objectActivities = activities.map { it.asObjectOrNull() } + val isCurrentShape = objectActivities.filterNotNull().any { + it.has("weekIndexes") || it.has("weekday") || it.has("startUnit") || it.has("endUnit") + } + if (isCurrentShape) { + if (objectActivities.any { item -> item == null || !CURRENT_SHUWEI_FIELDS.all(item::has) }) { + throw ImportFormatException("树维课程的星期、节次或周次字段不完整") + } + val current = objectActivities.filterNotNull() + return current.flatMap { item -> + val weeks = item.array("weekIndexes").mapNotNull { value -> + value.takeIf(JsonElement::isJsonPrimitive)?.asString?.toIntOrNull() + } + buildCourses( + name = item.string("courseName"), teacher = item.string("teachers"), + room = item.string("room"), day = item.int("weekday"), + sectionRuns = listOf( + (item.int("startUnit") ?: throw ImportFormatException("树维课程开始节次缺失")).. + (item.int("endUnit") ?: throw ImportFormatException("树维课程结束节次缺失")) + ), + weeks = weeks + ) + } + } + + // Older mobile pages expose one activity list per weekday/section cell. + val unitCount = container.int("unitCount") + ?: container.array("courseUnits").size.takeIf { it > 0 } + ?: throw ImportFormatException("树维课表节次数缺失") + if (unitCount !in 1..30) throw ImportFormatException("树维课表节次数异常") + data class LegacyActivity( + val name: String, + val teacher: String, + val room: String, + val day: Int, + val section: Int, + val weeks: List + ) + val legacy = activities.flatMapIndexed { index, cell -> + val items = cell.takeIf(JsonElement::isJsonArray)?.asJsonArray?.toList().orEmpty() + items.map { value -> + val item = value.asObjectOrNull() + ?: throw ImportFormatException("树维课程数据格式不完整") + LegacyActivity( + name = item.string("courseName").substringBefore('(').trim(), + teacher = item.string("teacherName"), room = item.string("roomName"), + day = index / unitCount + 1, + section = index % unitCount + 1, + weeks = bitmapWeeks(item.string("vaildWeeks"), allowLeadingZero = true) + ) + } + } + return legacy.groupBy { activity -> + listOf(activity.name, activity.teacher, activity.room, activity.day, activity.weeks) + }.values.flatMap { meetings -> + val first = meetings.first() + contiguousRuns(meetings.map(LegacyActivity::section).distinct()).flatMap { sections -> + buildCourses( + name = first.name, teacher = first.teacher, room = first.room, day = first.day, + sectionRuns = listOf(sections), weeks = first.weeks + ) + } + } + } + + private fun parseZhengfang(data: JsonElement?, html: String): List { + if (data == null) return parseHtml(AcademicSystem.ZHENGFANG_HTML, html) + val candidates = collectObjects(data).filter { row -> + val hasName = row.hasAny("kcmc", "kcm", "courseName") + val timeFieldCount = listOf( + row.hasAny("xqj", "xq", "skxq", "day", "weekday"), + row.hasAny("zcd", "zc", "skzc", "weeks"), + row.hasAny("jcs", "jc", "sksj", "ksjc", "skjc", "startSection") + ).count { it } + hasName && timeFieldCount > 0 || timeFieldCount >= 2 + } + if (candidates.isEmpty()) return parseHtml(AcademicSystem.ZHENGFANG_HTML, html) + candidates.forEach { row -> + if (row.firstString("kcmc", "kcm", "courseName").isBlank()) { + throw ImportFormatException("正方课程名称缺失", AcademicImportErrorCode.PARTIAL_PARSE) + } + if (row.firstString("xqj", "xq", "skxq", "day", "weekday").isBlank()) { + throw ImportFormatException("正方课程星期缺失", AcademicImportErrorCode.MISSING_DAY) + } + if (row.firstString("zcd", "zc", "skzc", "weeks").isBlank()) { + throw ImportFormatException("正方课程周次缺失", AcademicImportErrorCode.MISSING_WEEK) + } + if (row.firstString("jcs", "jc", "sksj", "ksjc", "skjc", "startSection").isBlank()) { + throw ImportFormatException("正方课程节次缺失", AcademicImportErrorCode.MISSING_SECTION) + } + } + return candidates.flatMap { row -> + val name = row.firstString("kcmc", "kcm", "courseName") + val sectionsText = row.firstString("jcs", "jc", "sksj") + val sections = if (sectionsText.isNotBlank()) { + explicitSectionRuns(sectionsText) + } else { + val start = row.firstInt("ksjc", "skjc", "startSection") + ?: throw ImportFormatException("$name 的开始节次缺失") + val end = row.firstInt("jsjc", "endSection") + ?: row.firstInt("cxjc", "sectionCount")?.takeIf { it > 0 }?.let { start + it - 1 } + ?: throw ImportFormatException("$name 的结束节次或持续节数缺失") + listOf(start..end) + } + if (sections.isEmpty()) throw ImportFormatException("$name 的节次无法确认") + buildCourses( + name = name, + teacher = row.firstString("xm", "jsxm", "jsmc", "teacher"), + room = row.firstString("cdmc", "jxcdmc", "jasmc", "classroom", "room"), + day = parseDay(row.firstString("xqj", "xq", "skxq", "day", "weekday")), + sectionRuns = sections, + weeks = explicitWeeks(row.firstString("zcd", "zc", "skzc", "weeks")) + ) + } + } + + private fun parseXbell(root: JsonElement): List { + val rows = collectObjects(root).filter { it.has("kcmc") && it.has("xqj") && it.has("djj") && it.has("qmz") } + if (rows.isEmpty()) throw ImportFormatException("未找到凌展课表数据") + return rows.flatMap { row -> + val weekType = when (row.int("dsz")) { + 0 -> 2 + 1 -> 1 + 2 -> 0 + else -> throw ImportFormatException("凌展课程单双周类型缺失") + } + val weeks = explicitWeeks(row.string("qmz")).filter { week -> + weekType == 0 || weekType == 1 && week % 2 == 1 || weekType == 2 && week % 2 == 0 + } + val section = row.int("djj") ?: throw ImportFormatException("凌展课程节次缺失") + buildCourses(row.string("kcmc"), row.string("jsxm"), row.string("skdd"), row.int("xqj"), + listOf(section..section), weeks) + } + } + + private fun parseChaoxing(root: JsonElement): List { + val rows = collectObjects(root).filter { it.has("kcmc") && it.has("xq") && it.has("djc") && it.has("zc") } + if (rows.isEmpty()) throw ImportFormatException("未找到超星教务课表数据") + return rows.flatMap { row -> + val weekType = row.int("zctype")?.takeIf { it in 0..2 } ?: 0 + val weeks = explicitWeeks(row.string("zc")).filter { week -> + weekType == 0 || weekType == 1 && week % 2 == 1 || weekType == 2 && week % 2 == 0 + } + val section = row.int("djc") ?: throw ImportFormatException("超星课程节次缺失") + buildCourses(row.string("kcmc"), row.string("tmc"), row.string("croommc"), row.int("xq"), + listOf(section..section), weeks) + } + } + + private fun parseChaoxingShare(root: JsonElement): List { + val rows = collectObjects(root).filter { + it.has("name") && it.has("dayOfWeek") && it.has("beginNumber") && it.has("length") && it.has("weeks") + } + if (rows.isEmpty()) throw ImportFormatException("未找到超星分享课表中的课程") + return rows.flatMap { row -> + val start = row.int("beginNumber") ?: throw ImportFormatException("超星分享课程节次缺失") + val length = row.int("length") ?: throw ImportFormatException("超星分享课程时长缺失") + buildCourses(row.string("name"), row.string("teacherName"), + row.string("location") + row.string("onlineLocation"), row.int("dayOfWeek"), + listOf(start..(start + length - 1)), explicitWeeks(row.string("weeks"))) + } + } + + private fun parseAic(html: String): List { + val document = checkedDocument(html) + val table = document.selectFirst("table#table") + ?: throw ImportFormatException("未找到 AIC 学期课表") + val rows = table.select("tr").toList().filter { it.closest("table") === table } + val courses = mutableListOf() + rows.drop(1).forEachIndexed { rowIndex, row -> + row.children().toList().filter { it.tagName() in setOf("td", "th") }.drop(1) + .forEachIndexed { dayIndex, cell -> + cell.select(".courseInfo").forEach { block -> + val name = block.children().firstOrNull()?.text().orEmpty().trim() + val weeks = block.selectFirst(".weekDetail")?.text()?.let(::explicitWeeks) + ?: throw ImportFormatException("AIC 课程周次缺失;请切换到学期课表") + buildCourses(name, block.select(".teacher").text(), block.select(".place").text(), + dayIndex + 1, listOf((rowIndex + 1)..(rowIndex + 1)), weeks).also(courses::addAll) + } + } + } + if (courses.isEmpty()) throw ImportFormatException("未找到 AIC 课程;请切换到学期课表") + return courses + } + + private fun parseHtml(system: AcademicSystem, html: String): List { + val document = checkedDocument(html) + val structured = parseStructuredTables(document) + if (structured.isNotEmpty()) return structured + val grid = parseGridTables(document, system) + if (grid.isNotEmpty()) return grid + throw ImportFormatException("当前页面没有完整的课程、星期、节次和周次,请按提示打开学期课表") + } + + private fun parseStructuredTables(document: Document): List { + document.select("table").forEach tableLoop@ { table -> + val rows = table.select("tr").toList().filter { it.closest("table") === table } + val headerIndex = rows.indexOfFirst { row -> + val headers = directCells(row).map { normalizedHeader(it.text()) } + headers.any { it in COURSE_HEADERS } && headers.any { it in DAY_HEADERS } && + headers.any { it in WEEK_HEADERS } && headers.any { it in SECTION_HEADERS || it in START_SECTION_HEADERS } + } + if (headerIndex < 0) return@tableLoop + val header = directCells(rows[headerIndex]).map { normalizedHeader(it.text()) } + val nameAt = header.indexOfFirst { it in COURSE_HEADERS } + val dayAt = header.indexOfFirst { it in DAY_HEADERS } + val weeksAt = header.indexOfFirst { it in WEEK_HEADERS } + val sectionAt = header.indexOfFirst { it in SECTION_HEADERS } + val startAt = header.indexOfFirst { it in START_SECTION_HEADERS } + val endAt = header.indexOfFirst { it in END_SECTION_HEADERS } + val countAt = header.indexOfFirst { it in SECTION_COUNT_HEADERS } + val teacherAt = header.indexOfFirst { it in TEACHER_HEADERS } + val roomAt = header.indexOfFirst { it in ROOM_HEADERS } + val parsed = mutableListOf() + rows.drop(headerIndex + 1).forEach rowLoop@ { row -> + val cells = directCells(row).map(Element::text) + val name = cells.at(nameAt) + if (name.isBlank()) return@rowLoop + val startEnd = when { + sectionAt >= 0 -> explicitSectionRuns(cells.at(sectionAt)).singleOrNull() + startAt >= 0 -> { + val start = firstInt(cells.at(startAt)) + ?: throw ImportFormatException("$name 的开始节次缺失") + val end = firstInt(cells.at(endAt)) + ?: firstInt(cells.at(countAt))?.takeIf { it > 0 }?.let { start + it - 1 } + ?: throw ImportFormatException("$name 的结束节次或持续节数缺失") + start..end + } + else -> null + } ?: throw ImportFormatException("$name 的节次无法确认") + parsed += buildCourses(name, cells.at(teacherAt), cells.at(roomAt), parseDay(cells.at(dayAt)), + listOf(startEnd), explicitWeeks(cells.at(weeksAt))) + } + if (parsed.isNotEmpty()) return parsed + } + return emptyList() + } + + private fun parseGridTables(document: Document, system: AcademicSystem): List { + val selectors = when (system) { + AcademicSystem.ZHENGFANG_HTML -> "#Table1,#kbgrid_table,#table1,#sycjlrtabGrid" + AcademicSystem.VATUU_HTML -> "#table_border,.table_border" + AcademicSystem.UMOOC_HTML -> "#timetable table,table#timetable,.timetable table" + AcademicSystem.SOUTH_SOFT_HTML -> "#kb,table.kb" + AcademicSystem.YILIAN_HTML -> "[class*=CourseTimeTable][class*=timeTable] table,table[class*=timeTable]" + AcademicSystem.KINGOSOFT_HTML -> ".pageRpt,#reportArea,#mytable,#kbDiv,table" + else -> "table" + } + val courses = mutableListOf() + var unreadable = 0 + document.select(selectors).toList().filter { it.tagName() == "table" }.forEach tableLoop@ { table -> + val rows = table.select("tr").toList().filter { it.closest("table") === table } + val headerIndex = rows.indexOfFirst { row -> directCells(row).count { parseDay(it.text()) != null } >= 2 } + if (headerIndex < 0) return@tableLoop + val dayColumns = directCells(rows[headerIndex]).mapIndexedNotNull { index, cell -> + parseDay(cell.text())?.let { index to it } + }.toMap() + rows.drop(headerIndex + 1).forEach rowLoop@ { row -> + val cells = directCells(row) + val fallback = cells.mapIndexedNotNull { index, cell -> + if (index in dayColumns) null else sectionRuns(cell.text()).firstOrNull() + }.firstOrNull() + dayColumns.forEach dayLoop@ { (column, day) -> + val cell = cells.getOrNull(column) ?: return@dayLoop + val blocks = leafCourseBlocks(cell) + blocks.forEach blockLoop@ { block -> + if (!containsWeek(block.text())) return@blockLoop + try { + courses += parseGridBlock(block, day, fallback) + } catch (_: ImportFormatException) { + unreadable++ + } + } + } + } + } + if (unreadable > 0) { + throw ImportFormatException("有 $unreadable 个课程块的周次或节次无法确认,本次未导入") + } + return courses + } + + private fun parseGridBlock(block: Element, day: Int, fallback: IntRange?): List { + val titled = block.select("[title],[data-original-title],[aria-describedby]") + fun titledText(labels: Set): String = titled.firstOrNull { element -> + val label = element.attr("title").ifBlank { element.attr("data-original-title") } + .ifBlank { element.attr("aria-describedby").substringAfterLast('_') } + normalizedHeader(label) in labels + }?.text().orEmpty().trim() + val lines = block.wholeText().lineSequence().map(String::trim).filter(String::isNotBlank).toList() + val weekLine = titled.firstOrNull { normalizedHeader(it.attr("title")) in WEEK_HEADERS }?.text() + ?: lines.firstOrNull(::containsWeek) + ?: throw ImportFormatException("课程周次缺失") + val weeks = explicitWeeks(extractWeekExpression(weekLine)) + val sections = sectionRuns(weekLine).ifEmpty { fallback?.let(::listOf).orEmpty() } + if (sections.isEmpty()) throw ImportFormatException("课程节次缺失") + val name = titledText(COURSE_HEADERS).ifBlank { + block.selectFirst(".title")?.text().orEmpty().trim() + }.ifBlank { + lines.firstOrNull { line -> + !containsWeek(line) && sectionRuns(line).isEmpty() && !isCourseCode(line) && + !line.startsWith("教师") && !line.startsWith("老师") && !line.startsWith("地点") && !line.startsWith("教室") + }.orEmpty().substringAfter(':').substringAfter(':').trim() + } + if (name.isBlank()) throw ImportFormatException("课程名称缺失") + val weekIndex = lines.indexOf(weekLine) + val teacher = titledText(TEACHER_HEADERS).ifBlank { labeled(block.text(), "教师|老师|任课教师") } + .ifBlank { lines.getOrNull(weekIndex - 1).takeUnless { it == name || it?.let(::containsWeek) == true }.orEmpty() } + val room = titledText(ROOM_HEADERS).ifBlank { labeled(block.text(), "教室|地点|上课地点") } + .ifBlank { lines.drop((weekIndex + 1).coerceAtLeast(0)).firstOrNull { sectionRuns(it).isEmpty() }.orEmpty() } + return buildCourses(name, teacher, room, day, sections, weeks) + } + + private fun buildCourses( + name: String, + teacher: String, + room: String, + day: Int?, + sectionRuns: List, + weeks: List + ): List { + val cleanName = name.trim() + if (cleanName.isBlank()) throw ImportFormatException("课程名称缺失") + if (day !in 1..7) throw ImportFormatException("$cleanName 的星期缺失或超出范围") + if (weeks.isEmpty()) throw ImportFormatException("$cleanName 的周次为空") + if (sectionRuns.isEmpty() || sectionRuns.any { it.first !in 1..30 || it.last !in it.first..30 }) { + throw ImportFormatException("$cleanName 的节次缺失或超出范围") + } + return sectionRuns.flatMap { sections -> + compressImportWeeks(weeks).map { range -> + Course(courseName = cleanName, teacher = teacher.trim(), classroom = room.trim(), + dayOfWeek = day!!, startSection = sections.first, endSection = sections.last, + startWeek = range.start, endWeek = range.end, weekType = range.weekType, + colorIndex = Math.floorMod(cleanName.hashCode(), 16)) + } + } + } + + private fun explicitWeeks(value: String): List { + if (value.isBlank()) throw ImportFormatException("课程周次缺失") + val normalized = value.replace(Regex("""[\[\]{}]"""), "").trim() + if (normalized.matches(Regex("[01]{1,53}"))) return bitmapWeeks(normalized) + return QiangzhiScheduleParser(totalWeeks).parseWeeks(normalized) + } + + private fun bitmapWeeks(value: String, allowLeadingZero: Boolean = false): List { + var bitmap = value.trim() + if (!bitmap.matches(Regex("[01]{1,53}"))) throw ImportFormatException("课程周次位图无效") + // EAMS/ShuWei vaildWeeks uses index 0 as a sentinel; real weeks start at index 1. + if (allowLeadingZero && bitmap.startsWith('0')) bitmap = bitmap.drop(1) + if (bitmap.length > 52) throw ImportFormatException("课程周次超出支持范围") + return bitmap.mapIndexedNotNull { index, bit -> (index + 1).takeIf { bit == '1' } } + .ifEmpty { throw ImportFormatException("课程周次为空") } + } + + private fun sectionRuns(value: String): List { + val match = Regex("(?:第|\\[)?\\s*(\\d{1,2}(?:\\s*[-~~—–-至,,、]\\s*\\d{1,2})*)\\s*节").find(value) + ?: value.trim().takeIf { it.matches(Regex("\\d{1,2}")) }?.let { return listOf(it.toInt()..it.toInt()) } + ?: return emptyList() + return contiguousRuns(explicitNumbers(match.groupValues[1], 30)) + } + + private fun explicitSectionRuns(value: String): List { + sectionRuns(value).takeIf(List::isNotEmpty)?.let { return it } + val clean = value.trim().replace('【', '[').replace('】', ']') + .trim('[', ']').removePrefix("第").removeSuffix("节").trim() + if (!clean.matches(Regex("\\d{1,2}(?:\\s*[-~~—–-至,,、]\\s*\\d{1,2})*"))) return emptyList() + return contiguousRuns(explicitNumbers(clean, 30)) + } + + private fun explicitNumbers(value: String, maximum: Int): List { + val result = sortedSetOf() + value.replace(Regex("[~~—–-至]"), "-").replace(',', ',').replace('、', ',') + .split(',').filter(String::isNotBlank).forEach { part -> + val range = Regex("\\s*(\\d{1,2})(?:\\s*-\\s*(\\d{1,2}))?\\s*").matchEntire(part) + ?: throw ImportFormatException("无法识别节次") + val start = range.groupValues[1].toInt() + val end = range.groupValues[2].toIntOrNull() ?: start + if (start !in 1..maximum || end !in start..maximum) throw ImportFormatException("节次超出范围") + result += start..end + } + return result.toList() + } + + private fun contiguousRuns(values: List): List = values.sorted().fold(mutableListOf()) { runs, value -> + val last = runs.lastOrNull() + if (last != null && last.last + 1 == value) runs[runs.lastIndex] = last.first..value else runs += value..value + runs + } + + private fun extractWeekExpression(value: String): String { + val normalized = value.replace('(', '(').replace(')', ')').replace(',', ',').replace(';', ';') + val sectionStart = Regex("(?:第|\\[)?\\s*\\d{1,2}(?:\\s*[-,]\\s*\\d{1,2})*\\s*节").find(normalized)?.range?.first + val withoutSections = if (sectionStart == null) normalized else normalized.substring(0, sectionStart) + val start = withoutSections.indexOfFirst(Char::isDigit) + if (start >= 0 && '周' in withoutSections.substring(start)) return withoutSections.substring(start).trim() + return Regex("[单双全]周").find(withoutSections)?.value + ?: throw ImportFormatException("课程周次缺失") + } + + private fun parseDay(value: String): Int? { + val text = value.trim().replace(Regex("\\s+"), "") + Regex("(?:星期|周|礼拜)?([一二三四五六日天])").find(text)?.let { + return "一二三四五六日".indexOf(it.groupValues[1].replace('天', '日')).plus(1).takeIf { day -> day > 0 } + } + return text.toIntOrNull()?.takeIf { it in 1..7 } + } + + private fun checkedDocument(html: String): Document { + if (html.isBlank()) throw ImportFormatException("未读取到课表页面内容") + return Jsoup.parse(html).also { document -> + document.select("script,style,input,select,textarea,button,iframe,frame,object,embed,form").remove() + } + } + + private fun leafCourseBlocks(cell: Element): List { + val leaves = cell.select("div").toList().filter { div -> + containsWeek(div.text()) && div.select("div").none { it !== div && containsWeek(it.text()) } + } + return leaves.ifEmpty { listOf(cell) } + } + + private fun containsWeek(value: String): Boolean = '周' in value && + (value.any(Char::isDigit) || value.contains('单') || value.contains('双') || value.contains('全')) + + private fun directCells(row: Element): List = row.children().toList().filter { it.tagName() in setOf("th", "td") } + private fun normalizedHeader(value: String): String = value.trim().lowercase().replace(Regex("[\\s_\\-/::]"), "") + private fun List.at(index: Int): String = getOrNull(index).orEmpty().trim() + private fun firstInt(value: String): Int? = Regex("\\d+").find(value)?.value?.toIntOrNull() + private fun labeled(value: String, labels: String): String = Regex("(?:$labels)\\s*[::]\\s*([^\\s,,;;]+)") + .find(value)?.groupValues?.getOrNull(1).orEmpty() + private fun isCourseCode(value: String) = value.matches(Regex("(?=.*\\d)[A-Za-z0-9_.-]{6,}")) + + private fun requireData(data: JsonElement?, label: String): JsonElement = data + ?: throw ImportFormatException("未读取到$label;请刷新课表页面、等待加载完成后重试") + + private fun collectObjects(root: JsonElement): List { + val result = mutableListOf() + var visited = 0 + fun visit(element: JsonElement?, depth: Int) { + if (element == null || element.isJsonNull || depth > 12 || ++visited > 12_000) return + when { + element.isJsonObject -> { + result += element.asJsonObject + element.asJsonObject.entrySet().forEach { visit(it.value, depth + 1) } + } + element.isJsonArray -> element.asJsonArray.forEach { visit(it, depth + 1) } + } + } + visit(root, 0) + return result + } + + private fun extractJavascriptArray(html: String, variable: String): JsonElement? { + val assignment = Regex("(?:var|let|const)\\s+${Regex.escape(variable)}\\s*=\\s*", RegexOption.IGNORE_CASE) + .find(html) ?: return null + val start = assignment.range.last + 1 + if (start !in html.indices || html[start] != '[') return null + var depth = 0 + var quote: Char? = null + var escaped = false + for (index in start until html.length) { + val character = html[index] + if (quote != null) { + when { + escaped -> escaped = false + character == '\\' -> escaped = true + character == quote -> quote = null + } + continue + } + when (character) { + '\'', '"', '`' -> quote = character + '[' -> depth++ + ']' -> { + depth-- + if (depth == 0) { + return runCatching { JsonParser.parseString(html.substring(start, index + 1)) }.getOrNull() + } + if (depth < 0) return null + } + } + } + return null + } + + private fun JsonElement.asObjectOrNull(): JsonObject? = takeIf(JsonElement::isJsonObject)?.asJsonObject + private fun JsonObject.string(key: String): String = get(key)?.takeUnless(JsonElement::isJsonNull)?.let { + runCatching { it.asString }.getOrNull() + }.orEmpty().trim() + private fun JsonObject.int(key: String): Int? = string(key).toIntOrNull() + private fun JsonObject.firstString(vararg keys: String): String = + keys.firstNotNullOfOrNull { key -> string(key).takeIf(String::isNotBlank) }.orEmpty() + private fun JsonObject.firstInt(vararg keys: String): Int? = + keys.firstNotNullOfOrNull { key -> string(key).toIntOrNull() } + private fun JsonObject.hasAny(vararg keys: String): Boolean = keys.any(::has) + private fun JsonObject.array(key: String): List = get(key)?.takeIf(JsonElement::isJsonArray)?.asJsonArray?.toList().orEmpty() + + private fun courseKey(course: Course): List = listOf(course.courseName, course.teacher, course.classroom, + course.dayOfWeek, course.startSection, course.endSection, course.startWeek, course.endWeek, course.weekType) + + companion object { + private val COURSE_HEADERS = setOf("课程", "课程名", "课程名称", "kcmc", "kcm", "course", "coursename") + private val TEACHER_HEADERS = setOf("教师", "老师", "任课教师", "授课教师", "代课教师", "teacher", "jsxm") + private val ROOM_HEADERS = setOf("教室", "地点", "上课地点", "教学楼", "classroom", "room", "location", "sksjdd") + private val DAY_HEADERS = setOf("星期", "周几", "上课日", "day", "weekday", "skxq") + private val WEEK_HEADERS = setOf("周次", "上课周", "weeks", "week", "skzc", "zc") + private val SECTION_HEADERS = setOf("节次", "上课节次", "section", "sections", "jcdm2", "sksj") + private val START_SECTION_HEADERS = setOf("开始节次", "起始节次", "startsection", "skjc") + private val END_SECTION_HEADERS = setOf("结束节次", "endsection", "jsjc") + private val SECTION_COUNT_HEADERS = setOf("节数", "持续节次", "sectioncount", "cxjc") + private val CURRENT_SHUWEI_FIELDS = setOf( + "courseName", "weekday", "startUnit", "endUnit", "weekIndexes" + ) + } +} diff --git a/app/src/main/java/com/courseschedule/ui/importdata/HitScheduleParser.kt b/app/src/main/java/com/courseschedule/ui/importdata/HitScheduleParser.kt new file mode 100644 index 0000000..a615503 --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/HitScheduleParser.kt @@ -0,0 +1,118 @@ +package com.courseschedule.ui.importdata + +import com.courseschedule.data.entity.Course +import org.jsoup.Jsoup +import org.jsoup.nodes.Element + +/** Parses the nine-column print grid used by HIT-family pages. */ +internal class HitScheduleParser(private val totalWeeks: Int) { + fun parse(html: String): ParsedImport { + val document = Jsoup.parse(html) + val roots = document.select(".xfyq_con,#xszp").toList() + if (roots.isEmpty()) throw ImportFormatException( + "未找到哈工大课表", + AcademicImportErrorCode.NO_TIMETABLE + ) + val courses = mutableListOf() + var unreadable = 0 + roots.flatMap { it.select("tr").toList() }.take(240).forEach rowLoop@{ row -> + val cells = directCells(row) + if (cells.size !in 8..9) return@rowLoop + val sectionColumn = if (cells.size == 8) 0 else 1 + val sections = parseSections(cells[sectionColumn].text()) ?: return@rowLoop + val dayStart = sectionColumn + 1 + (1..7).forEach dayLoop@{ day -> + val cell = cells.getOrNull(dayStart + day - 1) ?: return@dayLoop + val lines = cell.html().split(Regex("]*>|◇", RegexOption.IGNORE_CASE)) + .map { Jsoup.parseBodyFragment(it).text().trim() } + .filter(String::isNotBlank) + val markers = lines.indices.filter { WEEK.containsMatchIn(normalize(lines[it])) } + var lastName = "" + markers.forEachIndexed { markerPosition, markerIndex -> + val previous = lines.getOrNull(markerIndex - 1).orEmpty() + val name = if (WEEK.containsMatchIn(normalize(previous))) lastName else previous + if (name.isBlank()) { + unreadable++ + return@forEachIndexed + } + val nextMarker = markers.getOrNull(markerPosition + 1) + val groupEnd = nextMarker?.let { (it - 1).coerceAtLeast(markerIndex + 1) } ?: lines.size + val details = lines.subList(markerIndex, groupEnd).joinToString(" ") + runCatching { parseMeeting(name, details, day, sections) } + .onSuccess(courses::addAll) + .onFailure { unreadable++ } + lastName = name + } + } + } + if (unreadable > 0) throw ImportFormatException( + "有 $unreadable 个哈工大课程块字段不完整,本次未导入", + AcademicImportErrorCode.PARTIAL_PARSE + ) + if (courses.isEmpty()) throw ImportFormatException( + "哈工大课表页面没有课程", + AcademicImportErrorCode.NO_TIMETABLE + ) + return ParsedImport(courses.distinct(), sourceLabel = "哈工大教务 · HTML") + } + + private fun parseMeeting( + name: String, + details: String, + day: Int, + fallbackSections: IntRange + ): List { + val clean = normalize(details) + val weeks = QiangzhiScheduleParser(totalWeeks).parseWeeks( + WEEK.findAll(clean).joinToString(",") { it.value } + ) + val sections = SECTION.find(clean)?.let { match -> + val start = match.groupValues[1].toInt() + start..(match.groupValues[2].toIntOrNull() ?: start) + } ?: fallbackSections + if (sections.first !in 1..30 || sections.last !in sections.first..30) { + throw ImportFormatException("哈工大课程节次无效", AcademicImportErrorCode.MISSING_SECTION) + } + val teacher = labelValue(clean, "(?:任课|授课)?(?:教师|老师)") + val room = labelValue(clean, "(?:上课)?(?:地点|教室)") + return compressImportWeeks(weeks).map { range -> + Course( + courseName = name, + teacher = teacher, + classroom = room, + dayOfWeek = day, + startSection = sections.first, + endSection = sections.last, + startWeek = range.start, + endWeek = range.end, + weekType = range.weekType, + colorIndex = Math.floorMod(name.hashCode(), 16) + ) + } + } + + private fun parseSections(value: String): IntRange? { + val values = Regex("\\d{1,2}").findAll(value).map { it.value.toInt() }.toList() + val first = values.firstOrNull() ?: return null + val last = values.lastOrNull() ?: first + return (first..last).takeIf { first in 1..30 && last in first..30 } + } + + private fun labelValue(text: String, label: String): String = + Regex("(?:$label)\\s*[::]\\s*(.+?)(?=\\s*(?:教师|老师|地点|教室)\\s*[::]|[,,;;\\[\\]]|$)") + .find(text) + ?.groupValues?.get(1)?.trim().orEmpty() + + private fun normalize(value: String): String = value.replace('【', '[').replace('】', ']') + .replace('(', '(').replace(')', ')').replace(',', ',').replace('、', ',') + .replace(Regex("[~~—–-至]"), "-") + + private fun directCells(row: Element): List = row.children().toList().filter { + it.tagName() == "td" || it.tagName() == "th" + } + + companion object { + private val WEEK = Regex("\\d{1,2}(?:\\s*[-,]\\s*\\d{1,2})*\\s*周(?:\\s*\\(?[单双]\\)?)?") + private val SECTION = Regex("(?:第|\\[)?\\s*(\\d{1,2})(?:\\s*-\\s*(\\d{1,2}))?\\s*节\\s*]?") + } +} diff --git a/app/src/main/java/com/courseschedule/ui/importdata/HitszScheduleParser.kt b/app/src/main/java/com/courseschedule/ui/importdata/HitszScheduleParser.kt new file mode 100644 index 0000000..1b84442 --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/HitszScheduleParser.kt @@ -0,0 +1,100 @@ +package com.courseschedule.ui.importdata + +import com.courseschedule.data.entity.Course +import org.jsoup.Jsoup +import org.jsoup.nodes.Element + +/** Parses the card grid used by the HIT Shenzhen timetable page. */ +internal class HitszScheduleParser(private val totalWeeks: Int) { + fun parse(html: String): ParsedImport { + val body = Jsoup.parse(html).selectFirst(".ivu-table-tbody") + ?: throw ImportFormatException("未找到哈工深课表", AcademicImportErrorCode.NO_TIMETABLE) + val rows = body.select("tr").toList().filter { it.closest(".ivu-table-tbody") === body } + if (rows.size > 30) throw ImportFormatException( + "哈工深课表行数异常", + AcademicImportErrorCode.PAYLOAD_TOO_LARGE + ) + val courses = mutableListOf() + var unreadable = 0 + rows.forEach { row -> + directCells(row).forEachIndexed { columnIndex, cell -> + if (columnIndex !in 1..7) return@forEachIndexed + val cards = cell.select(".ivu-card-body").toList().filter { card -> + card.select(".ivu-card-body").none { nested -> nested !== card } + } + cards.forEach { card -> + runCatching { parseCard(card, columnIndex) } + .onSuccess(courses::addAll) + .onFailure { unreadable++ } + } + } + } + if (unreadable > 0) throw ImportFormatException( + "有 $unreadable 个哈工深课程卡片字段不完整,本次未导入", + AcademicImportErrorCode.PARTIAL_PARSE + ) + if (courses.isEmpty()) throw ImportFormatException( + "哈工深课表页面没有课程", + AcademicImportErrorCode.NO_TIMETABLE + ) + return ParsedImport(courses.distinct(), sourceLabel = "哈工深教务 · HTML") + } + + private fun parseCard(card: Element, day: Int): List { + val text = normalize(card.text()) + val name = text.split(Regex("\\s+")).firstOrNull().orEmpty() + if (name.isBlank()) throw ImportFormatException("哈工深课程名称缺失") + val section = SECTION.find(text) + ?: throw ImportFormatException("哈工深课程节次缺失", AcademicImportErrorCode.MISSING_SECTION) + val startSection = section.groupValues[1].toInt() + val endSection = section.groupValues[2].toIntOrNull() ?: startSection + if (startSection !in 1..30 || endSection !in startSection..30) { + throw ImportFormatException("哈工深课程节次无效", AcademicImportErrorCode.MISSING_SECTION) + } + val weekText = WEEK.findAll(text).joinToString(",") { it.value } + val weeks = try { + QiangzhiScheduleParser(totalWeeks).parseWeeks(weekText) + } catch (error: ImportFormatException) { + throw ImportFormatException( + error.message ?: "哈工深课程周次无效", + AcademicImportErrorCode.MISSING_WEEK + ) + } + val details = BRACKET.findAll(text).map { it.groupValues[1].trim() } + .filter { value -> + value.isNotBlank() && value != "实验" && !WEEK.containsMatchIn(value) && + !SECTION.containsMatchIn(value) + }.toList() + val teacher = if (name.startsWith("[实验]")) "" else details.firstOrNull().orEmpty() + val room = if (name.startsWith("[实验]")) details.lastOrNull().orEmpty() + else details.drop(1).lastOrNull().orEmpty() + return compressImportWeeks(weeks).map { range -> + Course( + courseName = name, + teacher = teacher, + classroom = room, + dayOfWeek = day, + startSection = startSection, + endSection = endSection, + startWeek = range.start, + endWeek = range.end, + weekType = range.weekType, + colorIndex = Math.floorMod(name.hashCode(), 16) + ) + } + } + + private fun normalize(value: String): String = value.replace('【', '[').replace('】', ']') + .replace('(', '(').replace(')', ')').replace(',', ',').replace('、', ',') + .replace(Regex("[~~—–-至]"), "-").replace(Regex("\\s+"), " ").trim() + + private fun directCells(row: Element): List = row.children().toList().filter { + it.tagName() == "td" || it.tagName() == "th" + } + + companion object { + private val WEEK = Regex("\\d{1,2}(?:\\s*[-,]\\s*\\d{1,2})*\\s*周(?:\\s*\\(?[单双]\\)?)?") + private val SECTION = Regex("(?:第|\\[)?\\s*(\\d{1,2})(?:\\s*-\\s*(\\d{1,2}))?\\s*节\\s*]?") + private val BRACKET = Regex("\\[([^]]+)]") + } +} diff --git a/app/src/main/java/com/courseschedule/ui/importdata/ImportActivity.kt b/app/src/main/java/com/courseschedule/ui/importdata/ImportActivity.kt index a3bce4e..7386761 100644 --- a/app/src/main/java/com/courseschedule/ui/importdata/ImportActivity.kt +++ b/app/src/main/java/com/courseschedule/ui/importdata/ImportActivity.kt @@ -1,15 +1,19 @@ package com.courseschedule.ui.importdata +import android.app.Activity import android.content.ClipboardManager import android.content.Context +import android.content.Intent import android.net.Uri import android.os.Bundle -import android.view.MenuItem import android.view.View +import android.view.animation.OvershootInterpolator +import android.view.animation.PathInterpolator import android.widget.TextView import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.doOnPreDraw import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import com.courseschedule.R @@ -17,6 +21,10 @@ import com.courseschedule.data.entity.Course import com.courseschedule.data.entity.Semester import com.courseschedule.databinding.ActivityImportBinding import com.courseschedule.domain.ScheduleRules +import com.courseschedule.ui.MainActivity +import com.courseschedule.ui.installPressScale +import com.courseschedule.ui.playNavigationMotion +import com.courseschedule.ui.settings.SettingsActivity import com.courseschedule.utils.ReminderManager import com.courseschedule.utils.SchedulePreferences import com.courseschedule.viewmodel.CourseViewModel @@ -34,23 +42,68 @@ class ImportActivity : AppCompatActivity() { private lateinit var binding: ActivityImportBinding private lateinit var courseViewModel: CourseViewModel private lateinit var semesterViewModel: SemesterViewModel + private val motionInterpolator = PathInterpolator(0.2f, 0.85f, 0.25f, 1f) private val filePicker = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> uri?.let(::importFromFile) } + private val schoolImportLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult + val school = result.data?.let(AcademicWebImportActivity::schoolFromIntent) + ?: return@registerForActivityResult + val json = result.data?.getStringExtra(AcademicWebImportActivity.EXTRA_SCHEDULE_JSON) + ?.takeIf(String::isNotBlank) + ?: return@registerForActivityResult + importParsed { totalWeeks -> AcademicSchools.parse(school, json, totalWeeks) } + } + + private val schoolPickerLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult + val data = result.data ?: return@registerForActivityResult + val entry = AcademicSchoolDirectory.find( + this, + data.getStringExtra(SchoolPickerActivity.EXTRA_DIRECTORY_ID) + ) ?: return@registerForActivityResult + val totalWeeks = courseViewModel.currentSemester.value?.totalWeeks ?: run { + Toast.makeText(this, R.string.semester_loading, Toast.LENGTH_SHORT).show() + return@registerForActivityResult + } + val school = AcademicWebImportActivity.schoolFromIntent(data) ?: entry.toAcademicSchool() ?: run { + Toast.makeText(this, R.string.academic_directory_entry_invalid, Toast.LENGTH_SHORT).show() + return@registerForActivityResult + } + launchAcademicImport(school, totalWeeks, entry.importHint) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityImportBinding.inflate(layoutInflater) setContentView(binding.root) setSupportActionBar(binding.toolbar) - supportActionBar?.setDisplayHomeAsUpEnabled(true) courseViewModel = ViewModelProvider(this)[CourseViewModel::class.java] semesterViewModel = ViewModelProvider(this)[SemesterViewModel::class.java] + binding.cardImportSchool.setOnClickListener { + val totalWeeks = courseViewModel.currentSemester.value?.totalWeeks + if (totalWeeks == null) { + Toast.makeText(this, R.string.semester_loading, Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + schoolPickerLauncher.launch(Intent(this, SchoolPickerActivity::class.java)) + } binding.cardImportJson.setOnClickListener { openFilePicker() } binding.cardImportText.setOnClickListener { showTextImportDialog() } + binding.cardImportSchool.installPressScale() + binding.cardImportJson.installPressScale() + binding.cardImportText.installPressScale() + initBottomNavigation() + animateImportEntrance() courseViewModel.currentSemester.observe(this) { semester -> binding.tvImportTarget.text = getString( R.string.import_target_format, @@ -59,6 +112,93 @@ class ImportActivity : AppCompatActivity() { } } + private fun initBottomNavigation() { + binding.bottomNavigation.selectedItemId = R.id.nav_import + binding.bottomNavigation.setOnItemSelectedListener { item -> + val itemView = binding.bottomNavigation.findViewById(item.itemId) + when (item.itemId) { + R.id.nav_home -> { + startActivity( + Intent(this, MainActivity::class.java).addFlags( + Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + ) + ) + overridePendingTransition(0, 0) + finish() + overridePendingTransition(0, 0) + true + } + R.id.nav_import -> { + itemView.playNavigationMotion() + true + } + R.id.nav_settings -> { + startActivity(Intent(this, SettingsActivity::class.java)) + overridePendingTransition(0, 0) + finish() + overridePendingTransition(0, 0) + true + } + else -> false + } + } + binding.bottomNavigation.setOnItemReselectedListener { item -> + binding.bottomNavigation.findViewById(item.itemId)?.playNavigationMotion() + } + binding.bottomNavigation.post { + binding.bottomNavigation.findViewById(R.id.nav_import)?.playNavigationMotion() + } + } + + private fun animateImportEntrance() { + val container = binding.importContent + val children = List(container.childCount, container::getChildAt) + children.forEach { child -> + child.alpha = 0.16f + child.translationY = dp(30f) + if (child === binding.cardImportSchool) { + child.scaleX = 0.9f + child.scaleY = 0.9f + } + } + binding.schoolIconContainer.apply { + scaleX = 0.2f + scaleY = 0.2f + rotation = -24f + } + container.doOnPreDraw { + children.forEachIndexed { index, child -> + child.animate() + .alpha(1f) + .translationY(0f) + .scaleX(1f) + .scaleY(1f) + .setStartDelay(index * 50L) + .setDuration(540L) + .setInterpolator(motionInterpolator) + .withLayer() + .start() + } + binding.schoolIconContainer.animate() + .scaleX(1f) + .scaleY(1f) + .rotation(0f) + .setStartDelay(150L) + .setDuration(720L) + .setInterpolator(OvershootInterpolator(1.55f)) + .withLayer() + .start() + } + } + + private fun dp(value: Float): Float = value * resources.displayMetrics.density + + private fun launchAcademicImport(school: AcademicSchool, totalWeeks: Int, hint: String? = null) { + schoolImportLauncher.launch(AcademicWebImportActivity.schoolIntent(this, school) + .putExtra(AcademicWebImportActivity.EXTRA_TOTAL_WEEKS, totalWeeks) + .putExtra(AcademicWebImportActivity.EXTRA_GENERIC_HINT, hint.orEmpty())) + } + private fun openFilePicker() { // Some Android file providers report CSV/HTML as application/octet-stream. // The parser validates the extension and content after selection. @@ -192,7 +332,8 @@ class ImportActivity : AppCompatActivity() { restoreMetadata.visibility = if (parsed.semester != null || parsed.settings != null) { View.VISIBLE } else View.GONE - restoreMetadata.isChecked = restoreMetadata.visibility == View.VISIBLE + // Importing courses must not silently undo a start date edited by the user. + restoreMetadata.isChecked = false replaceExisting.visibility = View.VISIBLE val dialog = MaterialAlertDialogBuilder(this) @@ -319,15 +460,9 @@ class ImportActivity : AppCompatActivity() { private fun setLoading(loading: Boolean) { binding.progressImport.visibility = if (loading) View.VISIBLE else View.GONE + binding.cardImportSchool.isEnabled = !loading binding.cardImportJson.isEnabled = !loading binding.cardImportText.isEnabled = !loading } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == android.R.id.home) { - finish() - return true - } - return super.onOptionsItemSelected(item) - } } diff --git a/app/src/main/java/com/courseschedule/ui/importdata/ImportParser.kt b/app/src/main/java/com/courseschedule/ui/importdata/ImportParser.kt index 9fed8d5..e868812 100644 --- a/app/src/main/java/com/courseschedule/ui/importdata/ImportParser.kt +++ b/app/src/main/java/com/courseschedule/ui/importdata/ImportParser.kt @@ -8,6 +8,9 @@ import com.google.gson.Gson import com.google.gson.JsonParser import com.google.gson.reflect.TypeToken import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import java.io.ByteArrayInputStream +import java.io.InputStream data class ParsedImport( val courses: List, @@ -16,7 +19,24 @@ data class ParsedImport( val sourceLabel: String = "" ) -class ImportFormatException(message: String) : IllegalArgumentException(message) +enum class AcademicImportErrorCode { + NO_TIMETABLE, + UNSUPPORTED_VARIANT, + MISSING_DAY, + MISSING_SECTION, + MISSING_WEEK, + PARTIAL_PARSE, + AMBIGUOUS_VARIANT, + FRAME_BLOCKED, + CAPTURE_MISS, + PAYLOAD_TOO_LARGE, + UNTRUSTED_SOURCE +} + +class ImportFormatException( + message: String, + val errorCode: AcademicImportErrorCode? = null +) : IllegalArgumentException(message) class ImportParser(private val defaultTotalWeeks: Int) { @@ -96,8 +116,26 @@ class ImportParser(private val defaultTotalWeeks: Int) { return ParsedImport(courses, sourceLabel = "文本") } - fun parseHtml(text: String): ParsedImport { - val document = Jsoup.parse(text) + fun parseHtml(text: String): ParsedImport = parseHtmlDocument(Jsoup.parse(text)) + + /** HTML exports can declare GBK/GB2312. Let Jsoup honor the charset before parsing. */ + fun parseHtml(input: InputStream): ParsedImport { + val bytes = ByteArray(2_000_001) + var size = 0 + while (size < bytes.size) { + val read = input.read(bytes, size, bytes.size - size) + if (read < 0) break + if (read == 0) throw ImportFormatException("HTML 文件读取中断,请重新选择文件") + size += read + } + if (size == bytes.size) throw ImportFormatException("HTML 文件过大,请只导出学期课表页面(不超过 2 MB)") + return parseHtmlDocument(Jsoup.parse(ByteArrayInputStream(bytes, 0, size), null, "")) + } + + private fun parseHtmlDocument(document: Document): ParsedImport { + if (document.getElementById("kbtable") != null) { + return QiangzhiScheduleParser(defaultTotalWeeks).parseDocument(document) + } val parsed = mutableListOf() document.select("table").forEach { table -> val rows = table.select("tr") diff --git a/app/src/main/java/com/courseschedule/ui/importdata/ImportWeekRanges.kt b/app/src/main/java/com/courseschedule/ui/importdata/ImportWeekRanges.kt new file mode 100644 index 0000000..42308ff --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/ImportWeekRanges.kt @@ -0,0 +1,21 @@ +package com.courseschedule.ui.importdata + +internal data class ImportWeekRange(val start: Int, val end: Int, val weekType: Int) + +/** Represent exactly the supplied weeks, never filling gaps between runs. */ +internal fun compressImportWeeks(weeks: List): List { + val sorted = weeks.distinct().sorted() + val groups = mutableListOf() + var index = 0 + while (index < sorted.size) { + val start = sorted[index] + val step = if (index + 1 < sorted.size && sorted[index + 1] - start == 2) 2 else 1 + var endIndex = index + while (endIndex + 1 < sorted.size && sorted[endIndex + 1] - sorted[endIndex] == step) endIndex++ + groups += ImportWeekRange(start, sorted[endIndex], if (step == 2) { + if (start % 2 == 0) 2 else 1 + } else 0) + index = endIndex + 1 + } + return groups +} diff --git a/app/src/main/java/com/courseschedule/ui/importdata/KingosoftScheduleParser.kt b/app/src/main/java/com/courseschedule/ui/importdata/KingosoftScheduleParser.kt new file mode 100644 index 0000000..9d209bf --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/KingosoftScheduleParser.kt @@ -0,0 +1,19 @@ +package com.courseschedule.ui.importdata + +internal class KingosoftScheduleParser( + totalWeeks: Int, + private val selectedResults: Boolean +) { + private val delegate = StrictTimetableTableParser( + totalWeeks = totalWeeks, + label = if (selectedResults) "青果正选结果" else "青果教学安排", + selectors = if (selectedResults) { + listOf("#kbDiv", "#mytable", ".pageRpt", "#reportArea") + } else { + listOf(".pageRpt", "#reportArea", "#mytable", "#kbDiv") + }, + rejectImageOnly = true + ) + + fun parse(html: String): ParsedImport = delegate.parse(html) +} diff --git a/app/src/main/java/com/courseschedule/ui/importdata/QiangzhiCaptureScript.kt b/app/src/main/java/com/courseschedule/ui/importdata/QiangzhiCaptureScript.kt new file mode 100644 index 0000000..9dd719a --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/QiangzhiCaptureScript.kt @@ -0,0 +1,110 @@ +package com.courseschedule.ui.importdata + +import com.google.gson.Gson + +/** One local snapshot via evaluateJavascript. No JavascriptInterface is exposed on Qiangzhi pages. */ +internal object QiangzhiCaptureScript { + fun create(school: AcademicSchool): String = SCRIPT + .replace("__ROUTES__", Gson().toJson(school.timetablePrefixes)) + .replace("__SCOPED__", school.isGeneric.toString()) + .replace("__LIMIT__", AcademicSchools.MAX_PAYLOAD_CHARS.toString()) + + private val SCRIPT = """ + (function () { + 'use strict'; + const routes = __ROUTES__; + const limit = __LIMIT__; + const visited = new Set(); + let blockedFrame = false; + function scope(url) { + const proxy = url.pathname.match(/^\/(https?(?:-[0-9]+)?)\/([^/]+)(?:\/|$)/i); + if (proxy) return url.origin + '/' + proxy[1] + '/' + proxy[2] + '/'; + if (/^\/(?:https?|wss?|tcp|udp|ftp)(?:[-/]|$)/i.test(url.pathname)) return null; + return url.origin + '/'; + } + function readable(doc) { + try { + const url = new URL(doc.defaultView.location.href); + const path = url.origin + url.pathname; + if (__SCOPED__) return routes.some(function (route) { return scope(url) === route; }); + return routes.some(function (route) { return path.startsWith(route); }); + } catch (_) { return false; } + } + function visible(element) { + if (!element || element.hidden || element.getAttribute('aria-hidden') === 'true') return false; + const style = element.ownerDocument.defaultView.getComputedStyle(element); + return style.display !== 'none' && style.visibility !== 'hidden'; + } + function find(doc, depth) { + if (!doc || depth > 5 || visited.has(doc) || visited.size >= 24) return null; + visited.add(doc); + const preferred = ['#kbtable','#kbtable1','#kbTable','table.kbtable','table[class*="kbtable" i]']; + const tables = []; + preferred.forEach(function (selector) { + try { doc.querySelectorAll(selector).forEach(function (table) { + if (!tables.includes(table)) tables.push(table); + }); } catch (_) {} + }); + doc.querySelectorAll('table').forEach(function (table) { + const value = (table.innerText || table.textContent || '').slice(0, 12000); + const days = new Set(value.match(/(?:星期|周|礼拜)?[一二三四五六日天]/g) || []); + if (days.size >= 2 && /周次|\d+\s*(?:[-~至]\s*\d+)?\s*周/.test(value) && + /节次|(?:第|\[)?\s*\d+\s*(?:[-~至]\s*\d+)?\s*节/.test(value) && !tables.includes(table)) tables.push(table); + }); + const table = tables.find(function (candidate) { + return candidate.tagName === 'TABLE' && visible(candidate) && candidate.getClientRects().length; + }); + if (readable(doc) && table && table.tagName === 'TABLE' && visible(table) && table.getClientRects().length) { + return {doc: doc, table: table}; + } + const frames = doc.querySelectorAll('iframe, frame'); + for (const frame of frames) { + if (!visible(frame)) continue; + try { + const child = frame.contentDocument; + if (!child) { blockedFrame = true; continue; } + const found = find(child, depth + 1); + if (found) return found; + } catch (_) { blockedFrame = true; } + } + return null; + } + try { + const found = find(document, 0); + if (!found) return JSON.stringify({error: blockedFrame ? 'frame' : 'table'}); + // Copy only table text/structure. Inputs (including passwords), script, href/src, + // event handlers, page body, Cookies and storage are never included in the result. + const tags = new Set(['TABLE','THEAD','TBODY','TFOOT','TR','TD','TH','DIV','SPAN','FONT','BR','HR','P','B','STRONG','I','EM','SMALL','A']); + const attributes = ['id', 'class', 'title', 'rowspan', 'colspan']; + let copied = 0; + function copy(node, depth) { + if (depth > 32 || ++copied > 15000) throw new Error('limit'); + if (node.nodeType === 3) return document.createTextNode(node.nodeValue || ''); + if (node.nodeType !== 1 || !tags.has(node.tagName) || !visible(node)) return null; + const clean = document.createElement(node.tagName === 'A' ? 'span' : node.tagName.toLowerCase()); + for (const name of attributes) { + if (node.hasAttribute(name)) clean.setAttribute(name, node.getAttribute(name).slice(0, 256)); + } + for (const child of node.childNodes) { + const safe = copy(child, depth + 1); + if (safe) clean.appendChild(safe); + } + return clean; + } + const table = copy(found.table, 0); + const selector = found.doc.querySelector('select#xnxq01id, select#xnxq, select#XNXQDM, select[name="xnxq01id"]'); + let term = ''; + if (selector) { + if (selector.tagName === 'SELECT' && selector.selectedIndex >= 0) { + term = selector.options[selector.selectedIndex].textContent || ''; + } + const value = String(selector.value || ''); + if (/^20[0-9]{2}[-_]20[0-9]{2}[-_][1-3]$/.test(value)) term = value; + } + const source = new URL(found.doc.defaultView.location.href); + const result = JSON.stringify({html: table.outerHTML, term: term.slice(0, 80), sourceUrl: source.origin + source.pathname}); + return result.length > limit ? JSON.stringify({error: 'large'}) : result; + } catch (_) { return JSON.stringify({error: 'capture'}); } + })(); + """.trimIndent() +} diff --git a/app/src/main/java/com/courseschedule/ui/importdata/QiangzhiScheduleParser.kt b/app/src/main/java/com/courseschedule/ui/importdata/QiangzhiScheduleParser.kt new file mode 100644 index 0000000..da5e00c --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/QiangzhiScheduleParser.kt @@ -0,0 +1,299 @@ +package com.courseschedule.ui.importdata + +import com.courseschedule.data.entity.Course +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element + +/** Parses the displayed Qiangzhi semester table, including both titled and older
layouts. + * No login, network requests, inferred class duration or guessed all-semester weeks live here. + */ +class QiangzhiScheduleParser( + private val totalWeeks: Int, + private val variantId: String = AcademicAdapterRegistry.QIANGZHI_AUTO +) { + fun parse(html: String): ParsedImport = parseDocument(Jsoup.parse(html)) + + internal fun matches(document: Document): Boolean = when (variantId) { + AcademicAdapterRegistry.QIANGZHI_2017 -> + document.selectFirst(".el-table__header") != null && + document.selectFirst(".el-table__body") != null + AcademicAdapterRegistry.QIANGZHI_2024 -> + document.selectFirst("[name=kbDataTd]") != null && + document.selectFirst(".qz-toolitiplists,.qz-tooltipContent-title") != null + AcademicAdapterRegistry.QIANGZHI_CRAZY -> document.selectFirst(".kbcontent1") != null + AcademicAdapterRegistry.QIANGZHI_BR -> + document.selectFirst("#kbtable .kbcontent br") != null + AcademicAdapterRegistry.QIANGZHI_NODE -> document.select("#kbtable .kbcontent").any { + it.childrenSize() > 1 && it.selectFirst("[title]") != null + } + AcademicAdapterRegistry.QIANGZHI_STANDARD -> document.selectFirst("#kbtable .kbcontent") != null + else -> document.selectFirst( + "#kbtable,.el-table__body,[name=kbDataTd],.kbcontent1" + ) != null + } + + internal fun parseDocument(document: Document): ParsedImport { + if (variantId == AcademicAdapterRegistry.QIANGZHI_2017) { + return parse2017Document(document) + } + val table = document.getElementById("kbtable")?.takeIf { it.tagName() == "table" } + ?: document.select("#kbtable1,#kbTable,table.kbtable,table[class*=kbtable]") + .firstOrNull { it.tagName() == "table" } + ?: document.select("table").firstOrNull { candidate -> + candidate.select("tr").any { row -> + directCells(row).count { parseDay(it.text()) != null } >= 2 + } && '周' in candidate.text() && + Regex("节次|第\\s*\\d+\\s*节").containsMatchIn(candidate.text()) + } + ?: throw ImportFormatException("未找到强智学期课表,请先打开「我的课表 → 学期理论课表」") + if (isHidden(table)) throw ImportFormatException("课表尚未显示,请先选择学期并点击查询") + val clean = table.clone() + clean.select("script, style, input, select, textarea, button, iframe, frame, object, embed").remove() + clean.getAllElements().toList().filter { isHidden(it) }.forEach(Element::remove) + val rows = clean.select("tr").toList().filter { it.closest("table") === clean } + if (rows.size > 240) throw ImportFormatException("课表行数异常,请只导入学期理论课表") + + val dayColumns = mutableMapOf() + val occupiedUntil = mutableMapOf() + val courses = mutableListOf() + var unreadableBlocks = 0 + val unreadableCodes = mutableSetOf() + rows.forEachIndexed { rowIndex, row -> + var column = 0 + val cells = row.children().toList().filter { it.tagName() in setOf("th", "td") }.map { cell -> + while ((occupiedUntil[column] ?: 0) > rowIndex) column++ + val span = cell.attr("colspan").toIntOrNull()?.coerceIn(1, 32) ?: 1 + val rowSpan = cell.attr("rowspan").toIntOrNull()?.coerceIn(1, 240) ?: 1 + val startColumn = column + repeat(span) { occupiedUntil[column++] = rowIndex + rowSpan } + startColumn to cell + } + val headings = cells.mapNotNull { (index, cell) -> parseDay(cell.text())?.let { index to it } } + if (headings.size >= 2) { + dayColumns.clear() + dayColumns.putAll(headings) + return@forEachIndexed + } + if (dayColumns.isEmpty()) return@forEachIndexed + val rowSections = cells.filter { it.first !in dayColumns }.firstNotNullOfOrNull { + sectionMatch(it.second.text())?.let { match -> parseNumbers(match.groupValues[1], 30) } + } + cells.forEach cellLoop@{ (index, cell) -> + val day = dayColumns[index] ?: return@cellLoop + val contentSelector = when (variantId) { + AcademicAdapterRegistry.QIANGZHI_CRAZY -> ".kbcontent1" + AcademicAdapterRegistry.QIANGZHI_2024 -> ".qz-toolitiplists" + else -> ".kbcontent" + } + val contents = cell.select(contentSelector).toList().filter { element -> + element.parents().toList().none { it !== cell && it.`is`(contentSelector) } + }.ifEmpty { + cell.select("div").toList().filter { div -> + hasTime(div) && div.select("div").none { it !== div && hasTime(it) } + }.ifEmpty { listOf(cell) } + } + contents.forEach { content -> + content.html().split(Regex("]*>|[-─—]{5,}", RegexOption.IGNORE_CASE)).forEach blockLoop@{ html -> + val block = Jsoup.parseBodyFragment(html).body() + if (block.text().isBlank() || block.text().trim() in setOf("无", "--", "-")) return@blockLoop + try { + val parsed = if (variantId == AcademicAdapterRegistry.QIANGZHI_2024) { + parse2024Block(block, day, rowSections) + } else { + parseBlock(block, day, rowSections) + } + if (parsed.isEmpty()) { + unreadableBlocks++ + unreadableCodes += when { + '周' !in block.text() -> AcademicImportErrorCode.MISSING_WEEK + sectionMatch(block.text()) == null && rowSections == null -> + AcademicImportErrorCode.MISSING_SECTION + else -> AcademicImportErrorCode.PARTIAL_PARSE + } + } else courses += parsed + } catch (error: ImportFormatException) { + unreadableBlocks++ + unreadableCodes += error.errorCode ?: AcademicImportErrorCode.PARTIAL_PARSE + } + } + } + } + } + if (dayColumns.isEmpty()) throw ImportFormatException( + "未识别到星期表头,请打开完整的学期理论课表", + AcademicImportErrorCode.MISSING_DAY + ) + if (unreadableBlocks > 0) throw ImportFormatException( + "有 $unreadableBlocks 个课程块的周次或节次无法确认,本次未导入。请使用完整学期课表,勿使用周课表或截图。", + unreadableCodes.singleOrNull() ?: AcademicImportErrorCode.PARTIAL_PARSE + ) + if (courses.isEmpty()) throw ImportFormatException("当前页面没有已排课课程,请确认已选择学期并点击查询") + // Some systems repeat a class in each spanned cell or provide duplicate print layouts. + return ParsedImport(courses.distinctBy { + listOf(it.courseName, it.teacher, it.classroom, it.dayOfWeek, it.startSection, + it.endSection, it.startWeek, it.endWeek, it.weekType) + }, sourceLabel = "强智教务 · HTML") + } + + private fun parse2017Document(document: Document): ParsedImport { + val header = document.select(".el-table__header tr").lastOrNull() + ?: throw ImportFormatException("未找到强智 2017 星期表头") + val bodyRows = document.select(".el-table__body tr") + if (bodyRows.isEmpty()) throw ImportFormatException("未找到强智 2017 课表内容") + val merged = Document.createShell("") + val table = merged.body().appendElement("table").attr("id", "kbtable") + table.appendChild(header.clone()) + bodyRows.take(240).forEach { table.appendChild(it.clone()) } + return QiangzhiScheduleParser( + totalWeeks, + AcademicAdapterRegistry.QIANGZHI_STANDARD + ).parseDocument(merged).copy(sourceLabel = "强智教务 · 2017") + } + + private fun parse2024Block(block: Element, day: Int, rowSections: List?): List { + val name = block.selectFirst(".qz-tooltipContent-title")?.text()?.trim() + ?: block.selectFirst("[title=课程名称],[title=课程名]")?.text()?.trim() + ?: throw ImportFormatException("课程名称缺失") + val text = block.wholeText() + val teacher = Regex("(?:老师|教师)\\s*[::]?\\s*([^\\s,,;;]+)") + .find(text)?.groupValues?.getOrNull(1).orEmpty() + val room = Regex("(?:地点|教室)\\s*[::]?\\s*([^\\n,,;;]+)") + .find(text)?.groupValues?.getOrNull(1).orEmpty().trim() + val times = block.select(".qz-tooltipContent-detailitem").map(Element::text) + .filter { '周' in it && sectionMatch(it) != null } + .ifEmpty { + Regex("\\d{1,2}(?:\\s*[-~~—–-至,,、]\\s*\\d{1,2})*\\s*周.{0,48}?(?:第|\\[)?\\s*\\d{1,2}(?:\\s*[-,]\\s*\\d{1,2})*\\s*节\\s*]?", RegexOption.DOT_MATCHES_ALL) + .findAll(text).map(MatchResult::value).toList() + } + if (times.isEmpty()) throw ImportFormatException("课程周次或节次缺失") + return times.flatMap { time -> + val withoutChineseLabel = time.substringAfter("时间:", time) + val value = withoutChineseLabel.substringAfter("时间:", withoutChineseLabel) + courses(name, teacher, room, day, value, rowSections) + } + } + + private fun parseBlock(block: Element, day: Int, rowSections: List?): List { + val titled = block.select("[title]").toList() + val timeFields = titled.filter { it.attr("title").contains("周次") } + val leading = block.clone().apply { select("[title]").remove() }.wholeText() + val name = titled.firstOrNull { it.attr("title") in setOf("课程名称", "课程名") }?.text() + ?: lines(leading).firstOrNull { !isCourseCode(it) } + ?: "" + if (timeFields.isNotEmpty()) { + if (name.isBlank()) throw ImportFormatException("课程名称缺失") + return timeFields.flatMap { time -> + val timeIndex = titled.indexOf(time) + val teacher = titled.take(timeIndex).lastOrNull { isTeacher(it.attr("title")) }?.text().orEmpty() + val room = titled.drop(timeIndex + 1).takeWhile { !it.attr("title").contains("周次") } + .firstOrNull { isRoom(it.attr("title")) }?.text() + ?: titled.firstOrNull { isRoom(it.attr("title")) }?.text().orEmpty() + courses(name.trim(), teacher.trim(), room.trim(), day, time.text(), rowSections) + } + } + + // Older Qiangzhi: name / teacher / 1-8周[1-2节] / classroom; a name can have several slots. + val rows = lines(block.wholeText()) + val timeIndexes = rows.indices.filter { '周' in rows[it] && sectionMatch(rows[it]) != null } + val oldName = rows.take(timeIndexes.firstOrNull() ?: 0).firstOrNull { !isCourseCode(it) }.orEmpty() + return timeIndexes.flatMap { index -> + if (oldName.isBlank() || index < 2 || index + 1 >= rows.size) throw ImportFormatException("课程字段不完整") + courses(oldName, rows[index - 1], rows[index + 1], day, rows[index], rowSections) + } + } + + private fun courses( + name: String, teacher: String, room: String, day: Int, + time: String, rowSections: List? + ): List { + val normalized = normalize(time) + val sectionsMatch = sectionMatch(normalized) + val sections = sectionsMatch?.let { parseNumbers(it.groupValues[1], 30) } ?: rowSections + ?: throw ImportFormatException("课程节次缺失", AcademicImportErrorCode.MISSING_SECTION) + val weeksText = if (sectionsMatch == null) normalized else normalized.removeRange(sectionsMatch.range) + val weeks = parseWeeks(weeksText) + val sectionRuns = sections.sorted().fold(mutableListOf()) { runs, section -> + val last = runs.lastOrNull() + if (last != null && last.last + 1 == section) runs[runs.lastIndex] = last.first..section + else runs += section..section + runs + } + return sectionRuns.flatMap { section -> + compressImportWeeks(weeks).map { range -> + Course(courseName = name, teacher = teacher, classroom = room, dayOfWeek = day, + startSection = section.first, endSection = section.last, + startWeek = range.start, endWeek = range.end, weekType = range.weekType, + colorIndex = Math.floorMod(name.hashCode(), 16)) + } + } + } + + internal fun parseWeeks(text: String): List { + val clean = normalize(text).replace("周次:", "").replace("(周)", "") + .replace("周", "").replace("第", "").replace(Regex("\\s+"), "") + .trim('[', ']', '{', '}', ':') + if (clean in setOf("单", "(单)", "双", "(双)")) { + return (1..totalWeeks.coerceIn(1, 52)).filter { (it % 2 == 1) == clean.contains('单') } + } + if (clean.isBlank()) throw ImportFormatException("课程周次缺失", AcademicImportErrorCode.MISSING_WEEK) + val result = sortedSetOf() + clean.split(',', ';').forEach { part -> + val match = Regex("(\\d{1,2})(?:-(\\d{1,2}))?(?:\\(([单双])\\)|([单双]))?").matchEntire(part) + ?: throw ImportFormatException("无法识别课程周次") + val first = match.groupValues[1].toInt() + val last = match.groupValues[2].toIntOrNull() ?: first + if (first !in 1..52 || last !in first..52) throw ImportFormatException("课程周次超出范围") + val marker = match.groupValues[3].ifBlank { match.groupValues[4] } + result += (first..last).filter { marker.isEmpty() || + (marker == "单" && it % 2 == 1) || (marker == "双" && it % 2 == 0) } + } + if (result.isEmpty()) throw ImportFormatException("课程周次为空") + return result.toList() + } + + private fun parseNumbers(value: String, maximum: Int): List { + val numbers = sortedSetOf() + normalize(value).replace(Regex("\\s+"), "").split(',').forEach { part -> + val match = Regex("(\\d{1,2})(?:-(\\d{1,2}))?").matchEntire(part) + ?: throw ImportFormatException("无法识别课程节次") + val start = match.groupValues[1].toInt() + val end = match.groupValues[2].toIntOrNull() ?: start + if (start !in 1..maximum || end !in start..maximum) throw ImportFormatException("课程节次超出范围") + numbers += start..end + } + return numbers.toList() + } + + private fun sectionMatch(text: String): MatchResult? = + Regex("(?:\\[|第)?\\s*(\\d{1,2}(?:\\s*[-,]\\s*\\d{1,2})*)\\s*节\\s*\\]?").find(normalize(text)) + + private fun normalize(text: String): String = text.replace('(', '(').replace(')', ')') + .replace('【', '[').replace('】', ']').replace(',', ',').replace('、', ',') + .replace(';', ';').replace(':', ':').replace(Regex("[~~—–-至]"), "-") + + private fun lines(text: String): List = text.replace('\u00a0', ' ').lineSequence() + .map(String::trim).filter(String::isNotBlank).toList() + + private fun isCourseCode(text: String): Boolean = + Regex("(?=.*\\d)[A-Za-z0-9_.\\-]{6,}").matches(text) + + private fun isTeacher(title: String) = title in setOf("老师", "教师", "任课教师", "授课教师") + private fun isRoom(title: String) = title in setOf("教室", "地点", "上课地点") + private fun hasTime(element: Element) = '周' in element.text() && sectionMatch(element.text()) != null + private fun isHidden(element: Element): Boolean = + sequenceOf(element).plus(element.parents().toList()).any { candidate -> + candidate.hasAttr("hidden") || candidate.attr("aria-hidden") == "true" || + Regex("(?:display\\s*:\\s*none|visibility\\s*:\\s*hidden)", RegexOption.IGNORE_CASE) + .containsMatchIn(candidate.attr("style")) + } + + private fun directCells(row: Element): List = row.children().toList() + .filter { it.tagName() in setOf("th", "td") } + + private fun parseDay(text: String): Int? { + val value = text.trim().replace(Regex("\\s+"), "") + val match = Regex("^(?:星期|周|礼拜)?([一二三四五六日天])(?:\\d.*)?$").matchEntire(value) ?: return null + return "一二三四五六日".indexOf(match.groupValues[1].replace('天', '日')).plus(1).takeIf { it > 0 } + } +} diff --git a/app/src/main/java/com/courseschedule/ui/importdata/ScauScheduleParser.kt b/app/src/main/java/com/courseschedule/ui/importdata/ScauScheduleParser.kt new file mode 100644 index 0000000..ebdd435 --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/ScauScheduleParser.kt @@ -0,0 +1,81 @@ +package com.courseschedule.ui.importdata + +import com.courseschedule.data.entity.Course +import org.jsoup.Jsoup +import org.jsoup.nodes.Element + +/** Parses the fixed two-sections-per-row print table used by SCAU. */ +internal class ScauScheduleParser(private val totalWeeks: Int) { + fun parse(html: String): ParsedImport { + val document = Jsoup.parse(html) + val table = document.select("table[border=1]").firstOrNull { + it.attr("bordercolor").equals("#000000", ignoreCase = true) + } ?: throw ImportFormatException( + "未找到华农打印课表", + AcademicImportErrorCode.NO_TIMETABLE + ) + val rows = table.select("tr").toList().filter { it.closest("table") === table }.drop(2) + if (rows.size > 30) throw ImportFormatException( + "华农课表行数异常", + AcademicImportErrorCode.PAYLOAD_TOO_LARGE + ) + val courses = mutableListOf() + rows.forEachIndexed { rowIndex, row -> + directCells(row).filter { it.attr("valign").equals("top", ignoreCase = true) } + .take(7).forEachIndexed cellLoop@{ dayIndex, cell -> + val lines = cell.html().split(Regex("]*>", RegexOption.IGNORE_CASE)) + .map { Jsoup.parseBodyFragment(it).text().trim() } + .filter(String::isNotBlank) + if (lines.size < 3) return@cellLoop + val titleAndTeacher = lines[0] + val rawName = titleAndTeacher.substringBefore(':').substringBefore(':').trim() + val nameParts = rawName.split(Regex("\\s+")).filter(String::isNotBlank) + val embeddedWeeks = nameParts.lastOrNull()?.takeIf { + it.matches(Regex("[\\d,,\\-~~—–-至]+")) + } + val name = if (embeddedWeeks == null) rawName else nameParts.dropLast(1).joinToString("") + if (name.isBlank()) throw ImportFormatException( + "华农课程名称缺失", + AcademicImportErrorCode.PARTIAL_PARSE + ) + val teacher = when { + ':' in titleAndTeacher -> titleAndTeacher.substringAfter(':') + ':' in titleAndTeacher -> titleAndTeacher.substringAfter(':') + else -> "" + }.trim() + val room = lines[1] + val weeks = try { + QiangzhiScheduleParser(totalWeeks).parseWeeks(embeddedWeeks ?: lines[2]) + } catch (error: ImportFormatException) { + throw ImportFormatException( + error.message ?: "华农课程周次无效", + AcademicImportErrorCode.MISSING_WEEK + ) + } + compressImportWeeks(weeks).forEach { range -> + courses += Course( + courseName = name, + teacher = teacher, + classroom = room, + dayOfWeek = dayIndex + 1, + startSection = rowIndex * 2 + 1, + endSection = rowIndex * 2 + 2, + startWeek = range.start, + endWeek = range.end, + weekType = range.weekType, + colorIndex = Math.floorMod(name.hashCode(), 16) + ) + } + } + } + if (courses.isEmpty()) throw ImportFormatException( + "华农课表页面没有课程", + AcademicImportErrorCode.NO_TIMETABLE + ) + return ParsedImport(courses.distinct(), sourceLabel = "华农教务 · HTML") + } + + private fun directCells(row: Element): List = row.children().toList().filter { + it.tagName() == "td" || it.tagName() == "th" + } +} diff --git a/app/src/main/java/com/courseschedule/ui/importdata/SchoolPickerActivity.kt b/app/src/main/java/com/courseschedule/ui/importdata/SchoolPickerActivity.kt new file mode 100644 index 0000000..20e8574 --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/SchoolPickerActivity.kt @@ -0,0 +1,179 @@ +package com.courseschedule.ui.importdata + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.widget.doAfterTextChanged +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import com.courseschedule.R +import com.courseschedule.databinding.DialogAcademicAddressBinding +import com.courseschedule.databinding.ActivitySchoolPickerBinding +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class SchoolPickerActivity : AppCompatActivity() { + + companion object { + const val EXTRA_DIRECTORY_ID = "academic_directory_id" + } + + private lateinit var binding: ActivitySchoolPickerBinding + private var allEntries: List = emptyList() + private val adapter = AcademicSchoolAdapter(::selectSchool) + private var selectedCategory: AcademicDirectoryCategory? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivitySchoolPickerBinding.inflate(layoutInflater) + setContentView(binding.root) + + setSupportActionBar(binding.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + binding.toolbar.setNavigationOnClickListener { finish() } + + binding.schoolList.layoutManager = LinearLayoutManager(this) + binding.schoolList.itemAnimator = null + binding.schoolList.adapter = adapter + binding.etSchoolSearch.doAfterTextChanged { updateResults(it?.toString().orEmpty()) } + binding.schoolCategoryGroup.setOnCheckedStateChangeListener { _, checkedIds -> + selectedCategory = when (checkedIds.firstOrNull()) { + R.id.chipUndergraduate -> AcademicDirectoryCategory.UNDERGRADUATE + R.id.chipGraduate -> AcademicDirectoryCategory.GRADUATE + R.id.chipCommonSystem -> AcademicDirectoryCategory.COMMON + else -> null + } + updateResults(binding.etSchoolSearch.text?.toString().orEmpty()) + } + binding.btnRequestSchool.setOnClickListener { openSchoolRequest() } + binding.tvSchoolCount.setText(R.string.academic_school_loading) + lifecycleScope.launch { + allEntries = withContext(Dispatchers.Default) { + AcademicSchoolDirectory.entries(applicationContext) + } + updateResults(binding.etSchoolSearch.text?.toString().orEmpty()) + } + } + + private fun updateResults(query: String) { + val categoryEntries = selectedCategory?.let { category -> + allEntries.filter { it.category == category } + } ?: allEntries + val results = AcademicSchoolDirectory.search(categoryEntries, query) + adapter.submitList(results) + binding.tvSchoolCount.text = getString( + if (query.isBlank() && selectedCategory == null) R.string.academic_school_count + else R.string.academic_school_search_count, + results.size + ) + binding.tvNoSchool.visibility = if (results.isEmpty()) View.VISIBLE else View.GONE + } + + private fun selectSchool(entry: AcademicSchoolDirectoryEntry) { + if (entry.needsUserUrl) { + showAddressInput(entry) + return + } + if (!entry.canImport) { + showAdapterRequired(entry) + return + } + if (entry.allowCleartext) { + confirmCleartext(entry.name, entry.host) { finishSelection(entry) } + return + } + finishSelection(entry) + } + + private fun showAddressInput(entry: AcademicSchoolDirectoryEntry) { + val profile = entry.profile ?: return showAdapterRequired(entry) + val content = DialogAcademicAddressBinding.inflate(layoutInflater) + content.tvGenericDescription.text = getString( + R.string.academic_generic_profile_dialog_hint, + profile.instructions + ) + content.tvWakeupReference.visibility = if (entry.referenceUrl.isBlank()) View.GONE else View.VISIBLE + content.tvWakeupReference.text = getString(R.string.academic_wakeup_reference, entry.referenceUrl) + + val dialog = MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.academic_generic_configure_title, entry.name)) + .setView(content.root) + .setPositiveButton(R.string.academic_trust_and_open, null) + .setNegativeButton(R.string.cancel, null) + .create() + dialog.setOnShowListener { + dialog.getButton(android.content.DialogInterface.BUTTON_POSITIVE).setOnClickListener { + content.tilGenericUrl.error = null + val school = runCatching { + entry.createUserConfiguredSchool(content.etGenericUrl.text?.toString().orEmpty()) + }.getOrElse { error -> + content.tilGenericUrl.error = error.message ?: getString(R.string.academic_directory_entry_invalid) + return@setOnClickListener + } + val finish = { + dialog.dismiss() + finishSelection(entry, school) + } + if (school.allowCleartext) { + confirmCleartext(entry.name, school.trustedHosts.first(), finish) + } else { + finish() + } + } + } + dialog.show() + } + + private fun confirmCleartext(name: String, host: String, onContinue: () -> Unit) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.academic_cleartext_title) + .setMessage(getString(R.string.academic_cleartext_detail, name, host)) + .setPositiveButton(R.string.academic_cleartext_continue) { _, _ -> onContinue() } + .setNegativeButton(R.string.cancel, null) + .show() + } + + private fun showAdapterRequired(entry: AcademicSchoolDirectoryEntry) { + MaterialAlertDialogBuilder(this) + .setTitle( + if (entry.isCloudOnly) R.string.academic_cloud_only_title + else R.string.academic_adapter_required_title + ) + .setMessage( + getString( + if (entry.isCloudOnly) R.string.academic_cloud_only_detail + else R.string.academic_adapter_required_detail, + entry.name + ) + ) + .setPositiveButton(R.string.academic_request_adapter) { _, _ -> openSchoolRequest() } + .setNegativeButton(R.string.cancel, null) + .show() + } + + private fun finishSelection(entry: AcademicSchoolDirectoryEntry) { + setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_DIRECTORY_ID, entry.id)) + finish() + } + + private fun finishSelection(entry: AcademicSchoolDirectoryEntry, school: AcademicSchool) { + setResult( + Activity.RESULT_OK, + AcademicWebImportActivity.schoolIntent(this, school) + .putExtra(EXTRA_DIRECTORY_ID, entry.id) + ) + finish() + } + + private fun openSchoolRequest() { + val uri = Uri.parse(getString(R.string.academic_school_request_url)) + runCatching { startActivity(Intent(Intent.ACTION_VIEW, uri)) } + .onFailure { Toast.makeText(this, R.string.academic_open_project_failed, Toast.LENGTH_SHORT).show() } + } +} diff --git a/app/src/main/java/com/courseschedule/ui/importdata/ShuweiPrintDataCapture.kt b/app/src/main/java/com/courseschedule/ui/importdata/ShuweiPrintDataCapture.kt new file mode 100644 index 0000000..cd39600 --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/ShuweiPrintDataCapture.kt @@ -0,0 +1,60 @@ +package com.courseschedule.ui.importdata + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import java.net.URI + +/** + * WakeUp-style network capture for the new ShuWei "for-std" architecture: + * the timetable JSON is fetched by the page from /print-data endpoints, so the + * WebView response for that request is transparently replayed with the session + * cookie and stashed for the import step. Only behavioral facts (the endpoint + * shape and JSON field names) are reused; the implementation is independent. + */ +internal object ShuweiPrintDataCapture { + + /** Hard ceiling; anything larger is abandoned and the page still renders normally. */ + const val MAX_STASH_BYTES = AcademicSchools.MAX_PAYLOAD_CHARS + + /** Matches only the timetable print-data JSON endpoint of for-std based deployments. */ + fun isCapturableUrl(url: String, allowCleartext: Boolean = false): Boolean { + val uri = academicWebUri(url, allowCleartext) ?: return false + return uri.rawPath.orEmpty().contains("/print-data", ignoreCase = true) + } + + /** + * Wraps the stashed JSON in the snapshot payload the import pipeline expects + * ({html, data, term, sourceUrl}). Returns null when the body is not JSON or + * exceeds the shared payload limit. + */ + fun wrapPayload(stashJson: String, sourceUrl: String, maxPayloadChars: Int): String? { + if (stashJson.isBlank() || stashJson.length > maxPayloadChars) return null + val data = runCatching { JsonParser.parseString(stashJson) }.getOrNull() ?: return null + if (!data.isJsonObject && !data.isJsonArray) return null + val wrapper = JsonObject().apply { + addProperty("html", "") + add("data", data) + addProperty("term", "") + addProperty("sourceUrl", sourceUrl) + } + val payload = wrapper.toString() + if (payload.length > maxPayloadChars) return null + return payload + } + + /** Splits an upstream Content-Type header into mimeType/encoding for pass-through. */ + fun splitContentType(contentType: String?): Pair { + if (contentType.isNullOrBlank()) return "application/json" to "utf-8" + val parts = contentType.split(';', limit = 2) + val mime = parts[0].trim().ifBlank { "application/json" } + val charset = parts.getOrNull(1) + ?.substringAfter("charset=", "") + ?.trim(' ', '"', '\'') + ?.takeIf { it.isNotBlank() && it.length <= 40 && !it.contains('\n') } + ?: "utf-8" + return mime to charset.lowercase() + } + + fun trimToLimit(bytes: ByteArray, limit: Int = MAX_STASH_BYTES): ByteArray? = + if (bytes.size > limit) null else bytes +} diff --git a/app/src/main/java/com/courseschedule/ui/importdata/SouthSoftScheduleParser.kt b/app/src/main/java/com/courseschedule/ui/importdata/SouthSoftScheduleParser.kt new file mode 100644 index 0000000..fd2f63a --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/SouthSoftScheduleParser.kt @@ -0,0 +1,11 @@ +package com.courseschedule.ui.importdata + +internal class SouthSoftScheduleParser(totalWeeks: Int) { + private val delegate = StrictTimetableTableParser( + totalWeeks = totalWeeks, + label = "南软教务", + selectors = listOf("#kb", "table.kb", ".kb") + ) + + fun parse(html: String): ParsedImport = delegate.parse(html) +} diff --git a/app/src/main/java/com/courseschedule/ui/importdata/StrictTimetableTableParser.kt b/app/src/main/java/com/courseschedule/ui/importdata/StrictTimetableTableParser.kt new file mode 100644 index 0000000..b5eefc9 --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/StrictTimetableTableParser.kt @@ -0,0 +1,405 @@ +package com.courseschedule.ui.importdata + +import com.courseschedule.data.entity.Course +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element + +/** Shared strict table mechanics for report-style Kingosoft and SouthSoft pages. */ +internal class StrictTimetableTableParser( + private val totalWeeks: Int, + private val label: String, + private val selectors: List, + private val rejectImageOnly: Boolean = false, + private val orderedRowsAreSections: Boolean = false, + private val splitDoubleBreaks: Boolean = false, + private val splitBoldBlocks: Boolean = false, + private val splitLeafCourseDivs: Boolean = false, + private val compactBraceFormat: Boolean = false, + private val defaultAllWeeks: Boolean = false +) { + fun matches(document: Document): Boolean = selectors.any { selector -> + runCatching { document.select(selector).isNotEmpty() }.getOrDefault(false) + } + + fun parse(html: String): ParsedImport { + val document = Jsoup.parse(html) + val roots = selectors.flatMap { selector -> + runCatching { document.select(selector).toList() }.getOrDefault(emptyList()) + }.distinct().filterNot(::isHidden) + if (roots.isEmpty()) { + throw ImportFormatException("未找到 $label 课表", AcademicImportErrorCode.NO_TIMETABLE) + } + val tables = roots.flatMap { root -> + if (root.tagName() == "table") listOf(root) else root.select("table").toList() + }.distinct().filterNot(::isHidden) + if (tables.isEmpty()) { + if (rejectImageOnly && roots.any { it.select("img,canvas,svg").isNotEmpty() }) { + throw ImportFormatException( + "$label 当前是图片课表,无法安全读取;请切换到报表或列表视图", + AcademicImportErrorCode.UNSUPPORTED_VARIANT + ) + } + throw ImportFormatException("未找到 $label 表格课表", AcademicImportErrorCode.NO_TIMETABLE) + } + + var matchedTable = false + val parsed = mutableListOf() + for (table in tables.take(12)) { + val expanded = expand(table) + val structured = parseStructured(expanded) + if (structured != null) { + matchedTable = true + parsed += structured + continue + } + val grid = parseGrid(expanded) + if (grid != null) { + matchedTable = true + parsed += grid + } + } + if (!matchedTable) { + throw ImportFormatException( + "$label 页面结构不受支持", + AcademicImportErrorCode.UNSUPPORTED_VARIANT + ) + } + if (parsed.isEmpty()) { + throw ImportFormatException("$label 页面没有课程", AcademicImportErrorCode.NO_TIMETABLE) + } + return ParsedImport(parsed.distinctBy(::courseKey), sourceLabel = "$label · HTML") + } + + private data class Slot( + val cell: Element, + val originRow: Int, + val originColumn: Int, + val rowSpan: Int, + val columnSpan: Int, + val origin: Boolean + ) + + private data class ExpandedTable(val rows: List>) + + private fun expand(table: Element): ExpandedTable { + val sourceRows = table.select("tr").toList().filter { it.closest("table") === table } + if (sourceRows.size > 240) { + throw ImportFormatException("$label 表格行数异常", AcademicImportErrorCode.PAYLOAD_TOO_LARGE) + } + val active = mutableMapOf() + val result = mutableListOf>() + var totalCells = 0 + sourceRows.forEachIndexed { rowIndex, row -> + val rowSlots = mutableMapOf() + active.forEach { (column, slot) -> + if (rowIndex < slot.originRow + slot.rowSpan) rowSlots[column] = slot.copy(origin = false) + } + var column = 0 + directCells(row).forEach { cell -> + while (rowSlots.containsKey(column)) column++ + val rowSpan = cell.attr("rowspan").toIntOrNull()?.coerceIn(1, 240) ?: 1 + val columnSpan = cell.attr("colspan").toIntOrNull()?.coerceIn(1, 32) ?: 1 + val slot = Slot(cell, rowIndex, column, rowSpan, columnSpan, origin = true) + repeat(columnSpan) { offset -> + rowSlots[column + offset] = if (offset == 0) slot else slot.copy(origin = false) + if (rowSpan > 1) active[column + offset] = slot + } + column += columnSpan + totalCells++ + if (totalCells > 18_000) { + throw ImportFormatException("$label 表格节点过多", AcademicImportErrorCode.PAYLOAD_TOO_LARGE) + } + } + val width = (rowSlots.keys.maxOrNull() ?: -1) + 1 + result += List(width) { rowSlots[it] } + active.entries.removeAll { (_, slot) -> rowIndex + 1 >= slot.originRow + slot.rowSpan } + } + return ExpandedTable(result) + } + + private fun parseStructured(table: ExpandedTable): List? { + val headerIndex = table.rows.indexOfFirst { row -> + val headers = row.map { normalizeHeader(it?.cell?.text().orEmpty()) } + headers.any { it in COURSE_HEADERS } && headers.any { it in DAY_HEADERS } && + headers.any { it in SECTION_HEADERS } && headers.any { it in WEEK_HEADERS } + } + if (headerIndex < 0) return null + val headers = table.rows[headerIndex].map { normalizeHeader(it?.cell?.text().orEmpty()) } + val nameColumn = headers.indexOfFirst { it in COURSE_HEADERS } + val dayColumn = headers.indexOfFirst { it in DAY_HEADERS } + val sectionColumn = headers.indexOfFirst { it in SECTION_HEADERS } + val weekColumn = headers.indexOfFirst { it in WEEK_HEADERS } + val teacherColumn = headers.indexOfFirst { it in TEACHER_HEADERS } + val roomColumn = headers.indexOfFirst { it in ROOM_HEADERS } + val result = mutableListOf() + table.rows.drop(headerIndex + 1).forEach { row -> + val values = row.map { it?.cell?.wholeText().orEmpty().trim() } + if (values.all(String::isBlank)) return@forEach + val name = values.valueAt(nameColumn) + if (name.isBlank()) { + throw ImportFormatException("$label 课程名称缺失", AcademicImportErrorCode.PARTIAL_PARSE) + } + val day = parseDay(values.valueAt(dayColumn)) + ?: throw ImportFormatException("$label 课程星期缺失", AcademicImportErrorCode.MISSING_DAY) + val sections = parseSections(values.valueAt(sectionColumn)) + val weeks = parseWeeks(values.valueAt(weekColumn)) + result += buildCourses( + name, values.valueAt(teacherColumn), values.valueAt(roomColumn), day, + listOf(sections to weeks) + ) + } + return result + } + + private fun parseGrid(table: ExpandedTable): List? { + val headerIndex = table.rows.indexOfFirst { row -> + row.mapNotNull { parseDay(it?.cell?.text().orEmpty()) }.distinct().size >= 2 + } + if (headerIndex < 0) return null + val dayColumns = table.rows[headerIndex].mapIndexedNotNull { index, slot -> + parseDay(slot?.cell?.text().orEmpty())?.let { index to it } + }.toMap() + val rowSections = table.rows.map { row -> + row.mapIndexedNotNull { column, slot -> + if (column in dayColumns || slot == null) null else parseSectionsOrNull(slot.cell.text()) + }.flatten().distinct().sorted() + } + val result = mutableListOf() + val handled = mutableSetOf>() + table.rows.forEachIndexed { rowIndex, row -> + if (rowIndex <= headerIndex) return@forEachIndexed + row.forEachIndexed cellLoop@{ column, slot -> + val day = dayColumns[column] ?: return@cellLoop + if (slot == null || !slot.origin || !handled.add(slot.originRow to slot.originColumn)) return@cellLoop + val text = slot.cell.wholeText().trim() + if (text.isBlank() || text in EMPTY_CELLS) return@cellLoop + val spannedDays = (slot.originColumn until slot.originColumn + slot.columnSpan) + .mapNotNull(dayColumns::get).distinct() + if (spannedDays.size != 1) { + throw ImportFormatException("$label 课程跨星期单元格无法确认", AcademicImportErrorCode.MISSING_DAY) + } + val explicitSections = (slot.originRow until slot.originRow + slot.rowSpan) + .flatMap { rowSections.getOrElse(it) { emptyList() } }.distinct().sorted() + val fallbackSections = explicitSections.ifEmpty { + if (!orderedRowsAreSections) emptyList() else { + val start = slot.originRow - headerIndex + (start until start + slot.rowSpan).toList() + } + } + val blocks = splitBlocks(slot.cell) + blocks.forEach { block -> + result += parseCourseBlock(block, day, fallbackSections) + } + } + } + return result + } + + private fun splitBlocks(cell: Element): List { + val preferred = cell.select( + ".kbcontent,.kbcontent1,.course,.courseInfo,.kb-item,[data-course]" + ).toList().filter { candidate -> + candidate.parents().toList().none { parent -> parent !== cell && + parent.`is`(".kbcontent,.kbcontent1,.course,.courseInfo,.kb-item,[data-course]") } + } + if (preferred.isNotEmpty()) return preferred + if (splitLeafCourseDivs) { + val leafDivs = cell.select("div").toList().filter { candidate -> + WEEK_TOKEN.containsMatchIn(candidate.text()) && candidate.select("div").none { nested -> + nested !== candidate && WEEK_TOKEN.containsMatchIn(nested.text()) + } + } + if (leafDivs.isNotEmpty()) return leafDivs + } + val separators = buildList { + if (splitDoubleBreaks) add("(?:]*>\\s*){2,}") + if (splitBoldBlocks) add("(?=]*>") + add("[-─—]{5,}") + }.joinToString("|") + return cell.html().split(Regex(separators, RegexOption.IGNORE_CASE)) + .map { Jsoup.parseBodyFragment(it).body() } + .filter { it.text().trim().isNotBlank() && it.text().trim() !in EMPTY_CELLS } + } + + private fun parseCourseBlock(block: Element, day: Int, fallbackSections: List): List { + val text = block.wholeText().replace('\u00a0', ' ').trim() + val lines = text.lineSequence().map(String::trim).filter(String::isNotBlank).toList() + val rawName = block.select("[title=课程名称],[title=课程名],[data-field=course]").firstOrNull()?.text()?.trim() + ?: extractLabel(text, "课程(?:名称|名)?") + ?: if (compactBraceFormat) lines.firstOrNull().orEmpty() else { + lines.firstOrNull { line -> + !WEEK_TOKEN.containsMatchIn(line) && !SECTION_TOKEN.containsMatchIn(line) && + !LABEL_ONLY.containsMatchIn(line) && !isCourseCode(line) + }.orEmpty() + } + val name = if (compactBraceFormat) rawName.substringBefore('{').substringBefore('{') + .substringBefore('(').substringBefore('(').trim() else rawName + if (name.isBlank()) { + throw ImportFormatException("$label 课程名称缺失", AcademicImportErrorCode.PARTIAL_PARSE) + } + val compactDetails = if (compactBraceFormat) { + Regex("[((]([^))]+)[))]").find(text)?.groupValues?.get(1) + ?.trim()?.split(Regex("\\s+")) + } else null + val teacher = extractLabel(text, "(?:任课|授课)?(?:教师|老师)") + ?: compactDetails?.firstOrNull().orEmpty() + val room = extractLabel(text, "(?:上课)?(?:地点|教室)") + ?: compactDetails?.lastOrNull().orEmpty() + val meetings = parseMeetingPairs(text, fallbackSections) + return buildCourses(name, teacher, room, day, meetings) + } + + private fun parseMeetingPairs( + text: String, + fallbackSections: List + ): List, List>> { + val result = mutableListOf, List>>() + WEEK_THEN_SECTION.findAll(normalize(text)).forEach { match -> + result += parseSections(match.groups["sections"]!!.value) to + parseWeeks(match.groups["weeks"]!!.value) + } + if (result.isEmpty()) { + SECTION_THEN_WEEK.findAll(normalize(text)).forEach { match -> + result += parseSections(match.groups["sections"]!!.value) to + parseWeeks(match.groups["weeks"]!!.value) + } + } + if (result.isNotEmpty()) return result.distinct() + val weekValues = WEEK_TOKEN.findAll(normalize(text)).flatMap { match -> + parseWeeks(match.value).asSequence() + }.distinct().toList().ifEmpty { + if (defaultAllWeeks) (1..totalWeeks).toList() else { + throw ImportFormatException("$label 课程周次缺失", AcademicImportErrorCode.MISSING_WEEK) + } + } + val sections = SECTION_TOKEN.find(normalize(text))?.value?.let(::parseSections) + ?: fallbackSections.takeIf(List::isNotEmpty) + ?: throw ImportFormatException("$label 课程节次缺失", AcademicImportErrorCode.MISSING_SECTION) + return listOf(sections to weekValues) + } + + private fun buildCourses( + rawName: String, + teacher: String, + room: String, + day: Int, + meetings: List, List>> + ): List { + val name = rawName.trim() + return meetings.flatMap { (sectionValues, weekValues) -> + contiguousRuns(sectionValues).flatMap { sections -> + compressImportWeeks(weekValues).map { weeks -> + Course( + courseName = name, + teacher = teacher.trim(), + classroom = room.trim(), + dayOfWeek = day, + startSection = sections.first, + endSection = sections.last, + startWeek = weeks.start, + endWeek = weeks.end, + weekType = weeks.weekType, + colorIndex = Math.floorMod(name.hashCode(), 16) + ) + } + } + } + } + + private fun parseWeeks(value: String): List = try { + QiangzhiScheduleParser(totalWeeks).parseWeeks(value) + } catch (error: ImportFormatException) { + throw ImportFormatException(error.message ?: "$label 周次无效", AcademicImportErrorCode.MISSING_WEEK) + } + + private fun parseSections(value: String): List = parseSectionsOrNull(value) + ?: throw ImportFormatException("$label 课程节次缺失", AcademicImportErrorCode.MISSING_SECTION) + + private fun parseSectionsOrNull(value: String): List? { + val match = SECTION_TOKEN.find(normalize(value)) ?: return null + val numbers = sortedSetOf() + match.groupValues[1].replace(',', ',').replace('、', ',') + .replace(Regex("[~~—–-至]"), "-").split(',').forEach { part -> + val range = Regex("\\s*(\\d{1,2})(?:\\s*-\\s*(\\d{1,2}))?\\s*").matchEntire(part) + ?: return null + val start = range.groupValues[1].toInt() + val end = range.groupValues[2].toIntOrNull() ?: start + if (start !in 1..30 || end !in start..30) return null + numbers += start..end + } + return numbers.toList().takeIf(List::isNotEmpty) + } + + private fun parseDay(value: String): Int? { + val match = Regex("(?:星期|周|礼拜)?([一二三四五六日天])").find(value.replace(Regex("\\s+"), "")) + ?: return null + return "一二三四五六日".indexOf(match.groupValues[1].replace('天', '日')) + .plus(1).takeIf { it in 1..7 } + } + + private fun extractLabel(text: String, label: String): String? = + Regex("(?:$label)\\s*[::]\\s*([^\\n,,;;]+)").find(text)?.groupValues?.get(1) + ?.trim()?.trimEnd(']', '}', '}', ')', ')') + + private fun normalize(value: String): String = value.replace('(', '(').replace(')', ')') + .replace('【', '[').replace('】', ']').replace(Regex("[~~—–-至]"), "-") + + private fun contiguousRuns(values: List): List { + val result = mutableListOf() + for (value in values.distinct().sorted()) { + val last = result.lastOrNull() + if (last != null && last.last + 1 == value) result[result.lastIndex] = last.first..value + else result += value..value + } + return result + } + + private fun directCells(row: Element): List = row.children().toList().filter { + it.tagName() == "td" || it.tagName() == "th" + } + + private fun isHidden(element: Element): Boolean = + sequenceOf(element).plus(element.parents().toList()).any { candidate -> + candidate.hasAttr("hidden") || candidate.attr("aria-hidden") == "true" || + Regex("display\\s*:\\s*none|visibility\\s*:\\s*hidden", RegexOption.IGNORE_CASE) + .containsMatchIn(candidate.attr("style")) + } + + private fun normalizeHeader(value: String): String = value.trim().lowercase() + .replace(Regex("[\\s_/-]"), "") + + private fun List.valueAt(index: Int): String = getOrElse(index) { "" } + private fun isCourseCode(value: String): Boolean = + value.matches(Regex("(?=.*\\d)[A-Za-z0-9_.-]{6,}")) + + private fun courseKey(course: Course): List = listOf( + course.courseName, course.teacher, course.classroom, course.dayOfWeek, + course.startSection, course.endSection, course.startWeek, course.endWeek, course.weekType + ) + + companion object { + private val EMPTY_CELLS = setOf("-", "--", "无", "暂无", "1") + private val WEEK_TOKEN = Regex( + "\\d{1,2}(?:\\s*[-~~—–-至,,、]\\s*\\d{1,2})*\\s*周(?:\\s*\\(?[单双]\\)?)?" + ) + private val SECTION_TOKEN = Regex( + "(?:第|\\[)?\\s*(\\d{1,2}(?:\\s*[-~~—–-至,,、]\\s*\\d{1,2})*)\\s*节\\s*]?" + ) + private val WEEK_THEN_SECTION = Regex( + "(?${WEEK_TOKEN.pattern}).{0,80}?(?${SECTION_TOKEN.pattern})" + ) + private val SECTION_THEN_WEEK = Regex( + "(?${SECTION_TOKEN.pattern}).{0,80}?(?${WEEK_TOKEN.pattern})" + ) + private val LABEL_ONLY = Regex("^(?:课程|课程名称|教师|老师|教室|地点|周次|节次)\\s*[::]") + private val COURSE_HEADERS = setOf("课程", "课程名", "课程名称", "coursename") + private val DAY_HEADERS = setOf("星期", "周几", "上课日", "weekday", "day") + private val SECTION_HEADERS = setOf("节次", "上课节次", "section", "sections") + private val WEEK_HEADERS = setOf("周次", "上课周", "week", "weeks") + private val TEACHER_HEADERS = setOf("教师", "老师", "任课教师", "授课教师", "teacher") + private val ROOM_HEADERS = setOf("教室", "地点", "上课地点", "room", "location") + } +} diff --git a/app/src/main/java/com/courseschedule/ui/importdata/UestcPostScheduleParser.kt b/app/src/main/java/com/courseschedule/ui/importdata/UestcPostScheduleParser.kt new file mode 100644 index 0000000..0cc9219 --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/UestcPostScheduleParser.kt @@ -0,0 +1,59 @@ +package com.courseschedule.ui.importdata + +import com.courseschedule.data.entity.Course +import org.jsoup.Jsoup +import org.jsoup.nodes.Element + +/** Parses the fixed slash-delimited graduate timetable grid used by UESTC. */ +internal class UestcPostScheduleParser(private val totalWeeks: Int) { + fun parse(html: String): ParsedImport { + val table = Jsoup.parse(html).getElementById("tbl") + ?: throw ImportFormatException("未找到电子科大研究生课表", AcademicImportErrorCode.NO_TIMETABLE) + val rows = table.select("tr").toList().filter { it.closest("table") === table }.drop(1) + val courses = mutableListOf() + var unreadable = 0 + rows.take(30).forEach { row -> + directCells(row).drop(1).take(7).forEachIndexed { dayIndex, cell -> + cell.text().split(Regex(",\\s+")).filter(String::isNotBlank).forEach blockLoop@{ value -> + val fields = value.split('/').map(String::trim) + if (fields.size < 8 || fields[1].isBlank()) { + unreadable++ + return@blockLoop + } + runCatching { + val sections = Regex("(\\d{1,2})\\s*[-~]\\s*(\\d{1,2})") + .find(fields[6]) ?: throw ImportFormatException("电子科大课程节次缺失") + val weeks = QiangzhiScheduleParser(totalWeeks).parseWeeks(fields[5]) + compressImportWeeks(weeks).map { range -> + Course( + courseName = fields[1], + teacher = fields[4], + classroom = fields[7], + dayOfWeek = dayIndex + 1, + startSection = sections.groupValues[1].toInt(), + endSection = sections.groupValues[2].toInt(), + startWeek = range.start, + endWeek = range.end, + weekType = range.weekType, + colorIndex = Math.floorMod(fields[1].hashCode(), 16) + ) + } + }.onSuccess(courses::addAll).onFailure { unreadable++ } + } + } + } + if (unreadable > 0) throw ImportFormatException( + "有 $unreadable 个电子科大课程块字段不完整,本次未导入", + AcademicImportErrorCode.PARTIAL_PARSE + ) + if (courses.isEmpty()) throw ImportFormatException( + "电子科大研究生课表页面没有课程", + AcademicImportErrorCode.NO_TIMETABLE + ) + return ParsedImport(courses.distinct(), sourceLabel = "电子科大研究生教务 · HTML") + } + + private fun directCells(row: Element): List = row.children().toList().filter { + it.tagName() == "td" || it.tagName() == "th" + } +} diff --git a/app/src/main/java/com/courseschedule/ui/importdata/WakeUpImportCatalog.kt b/app/src/main/java/com/courseschedule/ui/importdata/WakeUpImportCatalog.kt new file mode 100644 index 0000000..9a75cfd --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/WakeUpImportCatalog.kt @@ -0,0 +1,115 @@ +package com.courseschedule.ui.importdata + +/** + * Maps the WakeUp Schedule system-type labels kept in the bundled directory + * (sourceType) onto the local generic import profiles, and carries the + * per-type usage notes adapted from WakeUp's public import instructions. + * + * Only entries with a bundled, validated login URL are directly openable. + * Entries with a known local parser but no usable URL ask for a school-official + * address in the picker. + */ +internal object WakeUpImportCatalog { + + private val familyProfiles: Map = mapOf( + "zf" to "zhengfang", + "qz" to "qiangzhi", + "qz_2017" to "qiangzhi", + "qz_2024" to "qiangzhi", + "qz_crazy" to "qiangzhi", + "qz_br" to "qiangzhi", + "qz_with_node" to "qiangzhi", + "qz_ahut" to "qiangzhi", + "qz_bjfu" to "qiangzhi", + "qz_ustb" to "qiangzhi", + "qz_fspt" to "qiangzhi", + "qz_ecust" to "qiangzhi", + "qz_njust" to "qiangzhi", + "qz_single_node" to "qiangzhi", + "qz_old" to "qiangzhi", + "jlict_qz_old" to "qiangzhi", + "jz" to "wisedu", + "kingo_new" to "kingosoft_new", + "kg_zx" to "kingosoft_selected", + "qingguo" to "kingosoft_selected", + "south_soft" to "south_soft", + "suda_post" to "structured", + "zju_post" to "structured", + "xju_post" to "structured", + "cupl_post" to "structured", + "scau" to "structured", + "hitsz" to "structured", + "hit" to "structured", + "xhtd" to "structured", + "uestc_post" to "structured", + "gdei" to "structured", + "swjtu_post" to "wisedu", + "urp_new" to "urp_new", + "urp" to "urp", + "yl" to "yilian", + "aic" to "aic", + "umooc" to "umooc", + "cf" to "chengfang", + "cf_new" to "chengfang", + "vatuu" to "vatuu", + "shuwei_json" to "shuwei_easy", + "shuwei_m" to "shuwei_mobile", + "shuwei_new" to "shuwei_new", + "xatu_shuwei" to "shuwei", + "uestc_shuwei" to "shuwei", + "xsyu_shuwei" to "shuwei", + "hunnu_shuwei" to "shuwei", + "sias_shuwei" to "shuwei", + "login_chaoxing" to "chaoxing", + "chaoxing_share" to "chaoxing_share", + "login_xbellsoft" to "xbell", + "lz" to "xbell" + ) + + /** Provenance only: a reviewed directory row may provide an independent local fallback. */ + fun isCloudOnly(sourceType: String): Boolean = sourceType == "ziyan" + + /** Only types explicitly mapped to a locally implemented parser are offered. */ + fun profileIdFor(sourceType: String): String? = familyProfiles[sourceType] + + fun profileFor(sourceType: String): GenericAcademicProfile? = + profileIdFor(sourceType)?.let(GenericAcademicImport::profile) + + /** + * Usage notes adapted from WakeUp Schedule's published import steps for + * schools whose timetable page differs from the family default. + */ + fun hintFor(name: String, sourceType: String): String? = + hintsBySchool[name] ?: hintsByType[sourceType] + + private val hintsByType: Map = mapOf( + "cf_new" to "登录后进入「课表查询 → 我的课表」,只保留课表查询小窗,周次选择全部,再点击查询课表。", + "gdbyxy" to "登录后选择左栏「教学安排」→「教学安排表」,学年学期选好,格式选「格式一」,点击检索。", + "gxnu" to "登录后进入「已选选课列表」页面读取,不是「当前课程表」。", + "hust" to "登录后把课表切换为「按课程」显示并查询;时间地点为「待定」的课程不会导入,请后续手动添加。", + "javtc" to "登录系统后停在课表页面,直接读取即可。", + "jnu" to "登录后进入「选课管理系统 → 课程表及考试表」,需要多次操作并等待页面加载。", + "jxau" to "登录后进入「课表查询 → 本人课表查询 → 打印传统课表」页面。", + "bfa" to "登录后进入「修读课程查询 → 学期修读课程」并查询目标学期,不是「本学期分周课表」。", + "bfa_post" to "登录后进入「修读课程查询 → 学期修读课程」并查询目标学期,不是「本学期分周课表」。", + "cnu" to "登录后在「主页 → 全校课表」选择自己的专业,查询本学期课表后读取。", + "cqu" to "登录后点左上角菜单选择「我的课表」,能导入的是「我的课表」,不是选课管理。", + "kg_zx" to "登录后进入「网上选课 → 正选结果」;打不开或无数据说明是暂不支持的青果教务。", + "kingo_new" to "登录后进入「主控 → 教学安排」或「班级课表 → 格式二」,点击教务上的「检索」按钮,不要用导出或打印;图片格式的课表无法导入。", + "nju" to "使用统一身份认证登录后,打开「直观课表」并切换到「学期课表」再读取。", + "nwpu_post" to "翱翔门户登录后进入【研究生教育】应用,依次选择【课程与成绩】→【选课结果查询】,等页面完全加载后再读取。", + "xauat_post" to "登录后进入「教学与培养 → 课表查询」页面读取。", + "ruc" to "只适用于微人大「我的课程表(本+研)」页面,不要在选课系统页面使用;第 13–14 节的时间与教务系统不同,导入后请自行核对。", + "shtu_post" to "研究生请打开「我的培养 → 查看课表」再读取;本科生请改用树维教务导入。", + "shtu_post_2024" to "研究生请打开「我的培养 → 查看课表」再读取;本科生请改用树维教务导入。", + "shuwei_new" to "登录后进入「我的课表」页面,课表卡片可能加载较慢,等加载完成后再读取。", + "shuwei_m" to "在手机版课表页面等待课程全部加载后再读取。", + "sysu" to "可能需要校园网或校园 VPN;登录后首页课表不可导入,请打开类似「查询课表」的含全部周课程的页面。", + "hnjm" to "请把微信端课表页面的链接粘贴到地址栏,等页面加载完成后读取。" + ) + + private val hintsBySchool: Map = mapOf( + "西北农林科技大学" to "如果一直登录不上:在网址末尾斜杠后加上 hhh,登录跳到错误页后再删掉 hhh,按回车即可进入;随后打开「个人课表」(信息查询 → 学生个人课表)读取。", + "赣南医学院" to "选择个人课表后,周次选「全部周数」,切换到「图形」模式并勾选「放大」,否则可能只能导入某一周的课表。" + ) +} diff --git a/app/src/main/java/com/courseschedule/ui/importdata/WiseduScheduleParser.kt b/app/src/main/java/com/courseschedule/ui/importdata/WiseduScheduleParser.kt new file mode 100644 index 0000000..3cd2b9b --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/WiseduScheduleParser.kt @@ -0,0 +1,282 @@ +package com.courseschedule.ui.importdata + +import com.courseschedule.data.entity.Course +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser + +internal fun normalizeWiseduTerm(raw: String): String? { + val direct = Regex("(20\\d{2})\\s*[-_/]\\s*(20\\d{2})\\s*[-_/]\\s*([1-3])") + .find(raw) + if (direct != null) { + return "${direct.groupValues[1]}-${direct.groupValues[2]}-${direct.groupValues[3]}" + } + + val display = Regex( + "(20\\d{2})\\s*[-~~—–-至/]\\s*(20\\d{2})\\s*学年[\\s\\S]{0,16}?" + + "(秋季|春季|夏季|第一|第二|第三|1|2|3)\\s*学期" + ).find(raw) + if (display != null) { + val semester = when (display.groupValues[3]) { + "秋季", "第一", "1" -> 1 + "春季", "第二", "2" -> 2 + "夏季", "第三", "3" -> 3 + else -> return null + } + return "${display.groupValues[1]}-${display.groupValues[2]}-$semester" + } + + val compact = Regex("(? { + val direct = payload.objectOrNull("datas") + ?.objectOrNull("xskcb") + ?.arrayOrNull("rows") + ?.mapNotNull { it.takeIf(JsonElement::isJsonObject)?.asJsonObject } + .orEmpty() + if (direct.isNotEmpty()) return direct + + val fallback = payload.objectOrNull("xskcb") + ?.arrayOrNull("rows") + ?.mapNotNull { it.takeIf(JsonElement::isJsonObject)?.asJsonObject } + .orEmpty() + if (fallback.isNotEmpty()) return fallback + + val rootRows = payload.arrayOrNull("rows") + ?.mapNotNull { it.takeIf(JsonElement::isJsonObject)?.asJsonObject } + .orEmpty() + if (rootRows.isNotEmpty()) return rootRows + + return findCourseArray(payload, depth = 0, visited = intArrayOf(0)).orEmpty() + } + + private fun findCourseArray(element: JsonElement, depth: Int, visited: IntArray): List? { + if (depth > 10 || ++visited[0] > 12_000 || element.isJsonNull || element.isJsonPrimitive) return null + if (element.isJsonArray) { + val rows = element.asJsonArray + .mapNotNull { it.takeIf(JsonElement::isJsonObject)?.asJsonObject } + .filter { row -> + (row.has("KCM") || row.has("KCMC")) && row.has("SKXQ") && row.has("KSJC") || + row.has("KCMC") && row.has("PKSJDD") || + row.has("kcmc") && row.has("xqj") && row.has("djj") && row.has("qmz") + } + if (rows.isNotEmpty()) return rows + return element.asJsonArray.firstNotNullOfOrNull { child -> + findCourseArray(child, depth + 1, visited) + } + } + return element.asJsonObject.entrySet().firstNotNullOfOrNull { (_, child) -> + findCourseArray(child, depth + 1, visited) + } + } + + private fun parseRow(row: JsonObject): List { + if (row.has("kcmc")) return parseMobileRow(row) + if (row.has("KCMC") && row.has("PKSJDD")) return parsePackedRow(row) + val name = row.string("KCM").ifBlank { row.string("KCMC") }.trim() + fun number(key: String): Int? = if (requireExplicitTimes) row.string(key).trim().toIntOrNull() + else row.string(key).firstInteger() + val day = number("SKXQ") + val startSection = number("KSJC") + val endSection = number("JSJC") ?: startSection.takeUnless { requireExplicitTimes } + if (name.isBlank() || day == null || day !in 1..7 || startSection == null || endSection == null) { + if (requireExplicitTimes) throw ImportFormatException("课程名称、星期或节次字段不完整,本次未导入;此页面可能不是受支持的金智课表版本") + return emptyList() + } + + val teacher = row.string("SKJS").trim() + val classroom = row.string("JASMC").ifBlank { row.string("SKDD") }.trim() + val weekPattern = row.string("SKZC").trim() + val weekGroups = compressImportWeeks(parseWeeks(weekPattern)) + val colorIndex = Math.floorMod(name.hashCode(), 16) + + return weekGroups.map { group -> + Course( + courseName = name, + teacher = teacher, + classroom = classroom, + dayOfWeek = day, + startSection = startSection, + endSection = endSection, + startWeek = group.start, + endWeek = group.end, + weekType = group.weekType, + colorIndex = colorIndex + ) + } + } + + /** Some xkjglapp responses pack every meeting into a single PKSJDD string. */ + private fun parsePackedRow(row: JsonObject): List { + val name = row.string("KCMC").trim() + val meetings = row.string("PKSJDD").split(Regex("[;;\\n]+")) + .map(String::trim) + .filter(String::isNotBlank) + if (name.isBlank() || meetings.isEmpty()) { + throw ImportFormatException("金智课表的课程名称或上课安排字段不完整") + } + val teacher = row.string("RKJS").ifBlank { row.string("SKJS") }.trim() + val fallbackRoom = row.string("JASMC").ifBlank { row.string("SKDD") }.trim() + val dayPattern = Regex("(?:星期|周)([一二三四五六日天七])") + val sectionPattern = Regex("(\\d{1,2})(?:\\s*[-~~—–-至]\\s*(\\d{1,2}))?\\s*节") + val weekPattern = Regex( + "(?:\\d{1,2}\\s*(?:[-~~—–-至]\\s*\\d{1,2})?\\s*[,,、]?\\s*)+" + + "[单双]?\\s*周(?:\\s*[((][单双][))])?" + ) + val colorIndex = Math.floorMod(name.hashCode(), 16) + return meetings.flatMap { meeting -> + val dayText = dayPattern.find(meeting)?.groupValues?.get(1) + ?: throw ImportFormatException("金智课表的星期字段不完整") + val day = when (dayText) { + "天", "七" -> 7 + else -> "一二三四五六日".indexOf(dayText).plus(1) + } + val sectionMatch = sectionPattern.find(meeting) + ?: throw ImportFormatException("金智课表的节次字段不完整") + val startSection = sectionMatch.groupValues[1].toInt() + val endSection = sectionMatch.groupValues[2].toIntOrNull() ?: startSection + val weeks = weekPattern.findAll(meeting) + .flatMap { match -> parseWeeks(match.value).asSequence() } + .distinct() + .sorted() + .toList() + if (day !in 1..7 || startSection !in 1..30 || endSection !in startSection..30 || weeks.isEmpty()) { + throw ImportFormatException("金智课表的星期、节次或周次字段不完整") + } + val room = meeting.substring(sectionMatch.range.last + 1) + .trim().trimStart(']', '】', '、', ',', ',').trim() + .ifBlank { fallbackRoom } + compressImportWeeks(weeks).map { group -> + Course( + courseName = name, + teacher = teacher, + classroom = room, + dayOfWeek = day, + startSection = startSection, + endSection = endSection, + startWeek = group.start, + endWeek = group.end, + weekType = group.weekType, + colorIndex = colorIndex + ) + } + } + } + + private fun parseMobileRow(row: JsonObject): List { + val name = row.string("kcmc").trim() + val day = row.string("xqj").trim().toIntOrNull() + val section = row.string("djj").trim().toIntOrNull() + val weeks = parseWeeks(row.string("qmz")).filter { week -> + when (row.string("dsz").toIntOrNull()) { + 0 -> week % 2 == 0 + 1 -> week % 2 == 1 + 2 -> true + else -> throw ImportFormatException("金智移动课表的单双周字段不完整") + } + } + if (name.isBlank() || day !in 1..7 || section !in 1..30 || weeks.isEmpty()) { + throw ImportFormatException("金智移动课表的课程名称、星期、节次或周次字段不完整") + } + return compressImportWeeks(weeks).map { group -> + Course( + courseName = name, + teacher = row.string("jsxm").trim(), + classroom = row.string("skdd").trim(), + dayOfWeek = day!!, + startSection = section!!, + endSection = section, + startWeek = group.start, + endWeek = group.end, + weekType = group.weekType, + colorIndex = Math.floorMod(name.hashCode(), 16) + ) + } + } + + private fun parseWeeks(pattern: String): List { + if (requireExplicitTimes && pattern.isBlank()) throw ImportFormatException("课程周次缺失,本次未导入") + if (pattern.isBlank()) return (1..defaultTotalWeeks.coerceAtLeast(1)).toList() + + val normalized = pattern.trim() + if (normalized.matches(Regex("[01]+"))) { + if (requireExplicitTimes && normalized.length > 52) throw ImportFormatException("课程周次超出支持范围") + return normalized.mapIndexedNotNull { index, value -> + if (value == '1') index + 1 else null + } + } + if (requireExplicitTimes) return QiangzhiScheduleParser(defaultTotalWeeks).parseWeeks(normalized) + + val explicit = mutableSetOf() + Regex("(\\d+)\\s*(?:[-~~—–-]|至)\\s*(\\d+)").findAll(normalized).forEach { match -> + val start = match.groupValues[1].toIntOrNull() ?: return@forEach + val end = match.groupValues[2].toIntOrNull() ?: return@forEach + if (start > 0 && end >= start) explicit += start..end + } + Regex("\\d+").findAll(normalized) + .mapNotNull { it.value.toIntOrNull() } + .filter { it > 0 } + .forEach(explicit::add) + + val hasOddMarker = normalized.contains('单') + val hasEvenMarker = normalized.contains('双') + val candidates = explicit.ifEmpty { + (1..defaultTotalWeeks.coerceAtLeast(1)).toMutableSet() + } + return candidates.filter { week -> + when { + hasOddMarker && !hasEvenMarker -> week % 2 == 1 + hasEvenMarker && !hasOddMarker -> week % 2 == 0 + else -> true + } + }.sorted() + } + + private fun JsonObject.string(key: String): String = get(key) + ?.takeUnless { it.isJsonNull } + ?.let { element -> runCatching { element.asString }.getOrNull() } + .orEmpty() + + private fun JsonObject.objectOrNull(key: String): JsonObject? = get(key) + ?.takeIf(JsonElement::isJsonObject) + ?.asJsonObject + + private fun JsonObject.arrayOrNull(key: String) = get(key) + ?.takeIf(JsonElement::isJsonArray) + ?.asJsonArray + + private fun String.firstInteger(): Int? = Regex("\\d+").find(this)?.value?.toIntOrNull() +} diff --git a/app/src/main/java/com/courseschedule/ui/importdata/XhtdScheduleParser.kt b/app/src/main/java/com/courseschedule/ui/importdata/XhtdScheduleParser.kt new file mode 100644 index 0000000..31d37f0 --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/XhtdScheduleParser.kt @@ -0,0 +1,57 @@ +package com.courseschedule.ui.importdata + +import com.courseschedule.data.entity.Course +import org.jsoup.Jsoup + +/** Parses XHTD blocks whose div id stores the half-day row and weekday. */ +internal class XhtdScheduleParser(private val totalWeeks: Int) { + fun parse(html: String): ParsedImport { + val root = Jsoup.parse(html).getElementById("kbtable") + ?: throw ImportFormatException("未找到协和天地课表", AcademicImportErrorCode.NO_TIMETABLE) + val courses = mutableListOf() + var unreadable = 0 + root.select("div[id]").take(240).forEach { block -> + if (block.selectFirst("nobr") == null) return@forEach + val coordinate = Regex("^(\\d{1,2})-(\\d)$").matchEntire(block.id()) ?: return@forEach + val halfDayRow = coordinate.groupValues[1].toInt() + val day = coordinate.groupValues[2].toInt() + if (halfDayRow !in 1..15 || day !in 1..7) return@forEach + val lines = block.html().split(Regex("]*>", RegexOption.IGNORE_CASE)) + .map { Jsoup.parseBodyFragment(it).text().trim() } + for (index in lines.indices step 5) { + val fields = lines.drop(index).take(5) + if (fields.all(String::isBlank)) continue + if (fields.size < 5 || fields[0].isBlank() || fields[3].isBlank()) { + unreadable++ + continue + } + runCatching { + val weeks = QiangzhiScheduleParser(totalWeeks).parseWeeks(fields[3]) + compressImportWeeks(weeks).map { range -> + Course( + courseName = fields[0], + teacher = fields[2], + classroom = fields[4], + dayOfWeek = day, + startSection = (halfDayRow - 1) * 2 + 1, + endSection = (halfDayRow - 1) * 2 + 2, + startWeek = range.start, + endWeek = range.end, + weekType = range.weekType, + colorIndex = Math.floorMod(fields[0].hashCode(), 16) + ) + } + }.onSuccess(courses::addAll).onFailure { unreadable++ } + } + } + if (unreadable > 0) throw ImportFormatException( + "有 $unreadable 个协和天地课程块字段不完整,本次未导入", + AcademicImportErrorCode.PARTIAL_PARSE + ) + if (courses.isEmpty()) throw ImportFormatException( + "协和天地课表页面没有课程", + AcademicImportErrorCode.NO_TIMETABLE + ) + return ParsedImport(courses.distinct(), sourceLabel = "协和天地教务 · HTML") + } +} diff --git a/app/src/main/java/com/courseschedule/ui/settings/SettingsActivity.kt b/app/src/main/java/com/courseschedule/ui/settings/SettingsActivity.kt index 21e6906..93c6961 100644 --- a/app/src/main/java/com/courseschedule/ui/settings/SettingsActivity.kt +++ b/app/src/main/java/com/courseschedule/ui/settings/SettingsActivity.kt @@ -1,12 +1,16 @@ package com.courseschedule.ui.settings import android.app.DatePickerDialog +import android.content.ActivityNotFoundException +import android.content.ClipData +import android.content.ClipboardManager import android.content.Intent +import android.net.Uri import android.os.Bundle import android.text.InputType -import android.view.MenuItem import android.view.View import android.view.ViewGroup +import android.view.animation.PathInterpolator import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.AutoCompleteTextView @@ -14,6 +18,7 @@ import android.widget.LinearLayout import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.doOnPreDraw import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import com.courseschedule.R @@ -23,6 +28,8 @@ import com.courseschedule.data.entity.Semester import com.courseschedule.databinding.ActivitySettingsBinding import com.courseschedule.domain.ScheduleRules import com.courseschedule.domain.SemesterPhase +import com.courseschedule.ui.installPressScale +import com.courseschedule.ui.playNavigationMotion import com.courseschedule.ui.importdata.ImportActivity import com.courseschedule.utils.ReminderManager import com.courseschedule.utils.SchedulePreferences @@ -47,6 +54,8 @@ class SettingsActivity : AppCompatActivity() { private lateinit var courseViewModel: CourseViewModel private lateinit var preferences: SchedulePreferences private var semesters: List = emptyList() + private var suppressBottomNavigationMotion = false + private val motionInterpolator = PathInterpolator(0.22f, 1f, 0.36f, 1f) private val sectionHeightValues = intArrayOf(56, 64, 72, 84) private val reminderValues = intArrayOf(-1, 5, 10, 15, 30, 60) @@ -61,18 +70,20 @@ class SettingsActivity : AppCompatActivity() { setContentView(binding.root) setSupportActionBar(binding.toolbar) - supportActionBar?.setDisplayHomeAsUpEnabled(true) semesterViewModel = ViewModelProvider(this)[SemesterViewModel::class.java] courseViewModel = ViewModelProvider(this)[CourseViewModel::class.java] preferences = SchedulePreferences(this) initSettingsControls() initActions() + initBottomNavigation() observeData() + animateSettingsEntrance() } private fun initSettingsControls() { binding.switchShowWeekend.isChecked = preferences.showWeekend + binding.switchShowInactiveCourses.isChecked = preferences.showInactiveCourses binding.switchShowTime.isChecked = preferences.showTime binding.switchReminder.isChecked = preferences.reminderEnabled @@ -87,18 +98,34 @@ class SettingsActivity : AppCompatActivity() { binding.spinnerDefaultReminder.setSelection( reminderValues.indexOf(preferences.defaultReminderMinutes).takeIf { it >= 0 } ?: 3 ) - updateReminderControlState(preferences.reminderEnabled) + updateReminderControlState(preferences.reminderEnabled, animate = false) updateSectionTimesSummary() + binding.rowShowWeekend.setOnClickListener { + binding.switchShowWeekend.toggle() + } + binding.rowShowInactiveCourses.setOnClickListener { + binding.switchShowInactiveCourses.toggle() + } + binding.rowShowTime.setOnClickListener { + binding.switchShowTime.toggle() + } + binding.rowReminder.setOnClickListener { + binding.switchReminder.toggle() + } + binding.switchShowWeekend.setOnCheckedChangeListener { _, checked -> preferences.showWeekend = checked } + binding.switchShowInactiveCourses.setOnCheckedChangeListener { _, checked -> + preferences.showInactiveCourses = checked + } binding.switchShowTime.setOnCheckedChangeListener { _, checked -> preferences.showTime = checked } binding.switchReminder.setOnCheckedChangeListener { _, checked -> preferences.reminderEnabled = checked - updateReminderControlState(checked) + updateReminderControlState(checked, animate = true) updateReminderScheduling(checked) } binding.spinnerSectionHeight.onItemSelectedListener = onItemSelected { position -> @@ -124,11 +151,26 @@ class SettingsActivity : AppCompatActivity() { } } - private fun updateReminderControlState(enabled: Boolean) { + private fun updateReminderControlState(enabled: Boolean, animate: Boolean) { binding.spinnerDefaultReminder.isEnabled = enabled - binding.spinnerDefaultReminder.alpha = if (enabled) 1f else 0.45f binding.cardSectionTimes.isEnabled = enabled - binding.cardSectionTimes.alpha = if (enabled) 1f else 0.55f + val spinnerAlpha = if (enabled) 1f else 0.45f + val sectionTimesAlpha = if (enabled) 1f else 0.55f + if (animate) { + binding.spinnerDefaultReminder.animate() + .alpha(spinnerAlpha) + .setDuration(220L) + .setInterpolator(motionInterpolator) + .start() + binding.cardSectionTimes.animate() + .alpha(sectionTimesAlpha) + .setDuration(220L) + .setInterpolator(motionInterpolator) + .start() + } else { + binding.spinnerDefaultReminder.alpha = spinnerAlpha + binding.cardSectionTimes.alpha = sectionTimesAlpha + } } private fun updateReminderScheduling(enabled: Boolean) { @@ -142,13 +184,86 @@ class SettingsActivity : AppCompatActivity() { } private fun initActions() { + binding.cardOpenSource.setOnClickListener { openProjectRepository() } + binding.cardOpenSource.setOnLongClickListener { + copyProjectAddress() + true + } binding.cardSemester.setOnClickListener { showSemesterManager() } binding.cardSectionTimes.setOnClickListener { showSectionTimesDialog() } binding.cardExport.setOnClickListener { exportData() } binding.cardBackup.setOnClickListener { startActivity(Intent(this, ImportActivity::class.java)) + overridePendingTransition(0, 0) } binding.cardAbout.setOnClickListener { showAboutDialog() } + + listOf( + binding.cardOpenSource, + binding.cardSemester, + binding.rowShowWeekend, + binding.rowShowInactiveCourses, + binding.rowShowTime, + binding.rowReminder, + binding.cardSectionTimes, + binding.cardExport, + binding.cardBackup, + binding.cardAbout + ).forEach { it.installPressScale() } + } + + private fun initBottomNavigation() { + binding.bottomNavigation.selectedItemId = R.id.nav_settings + binding.bottomNavigation.setOnItemSelectedListener { item -> + if (suppressBottomNavigationMotion) return@setOnItemSelectedListener true + val itemView = binding.bottomNavigation.findViewById(item.itemId) + when (item.itemId) { + R.id.nav_home -> { + finish() + overridePendingTransition(0, 0) + true + } + R.id.nav_import -> { + startActivity(Intent(this, ImportActivity::class.java)) + overridePendingTransition(0, 0) + finish() + overridePendingTransition(0, 0) + true + } + R.id.nav_settings -> { + itemView.playNavigationMotion() + true + } + else -> false + } + } + binding.bottomNavigation.setOnItemReselectedListener { item -> + binding.bottomNavigation.findViewById(item.itemId)?.playNavigationMotion() + } + binding.bottomNavigation.post { + binding.bottomNavigation.findViewById(R.id.nav_settings)?.playNavigationMotion() + } + } + + private fun animateSettingsEntrance() { + val container = binding.settingsContent + val children = List(container.childCount, container::getChildAt) + children.forEach { child -> + child.alpha = 0.18f + child.translationX = dp(32).toFloat() + } + container.doOnPreDraw { + children.forEachIndexed { index, child -> + child.animate() + .alpha(1f) + .translationX(0f) + .setStartDelay(index * 56L) + .setDuration(620L) + .setInterpolator(motionInterpolator) + .withLayer() + .start() + } + } } private fun observeData() { @@ -224,6 +339,7 @@ class SettingsActivity : AppCompatActivity() { private fun showSemesterEditor(existing: Semester?) { val dialogView = layoutInflater.inflate(R.layout.dialog_semester, null) val nameLayout = dialogView.findViewById(R.id.tilSemesterName) + val dateLayout = dialogView.findViewById(R.id.tilSemesterStartDate) val nameInput = dialogView.findViewById(R.id.etSemesterName) val dateInput = dialogView.findViewById(R.id.etSemesterStartDate) val weeksDropdown = dialogView.findViewById(R.id.dropdownTotalWeeks) @@ -238,7 +354,7 @@ class SettingsActivity : AppCompatActivity() { getString(R.string.week_count_format, existing?.totalWeeks?.coerceIn(12, 30) ?: 20), false ) - dateInput.setOnClickListener { + val showDatePicker = View.OnClickListener { val calendar = Calendar.getInstance().apply { timeInMillis = selectedStartDate } DatePickerDialog( this, @@ -253,6 +369,8 @@ class SettingsActivity : AppCompatActivity() { calendar.get(Calendar.DAY_OF_MONTH) ).show() } + dateInput.setOnClickListener(showDatePicker) + dateLayout.setEndIconOnClickListener(showDatePicker) val dialog = MaterialAlertDialogBuilder(this) .setTitle(if (existing == null) R.string.add_semester else R.string.edit_semester) @@ -270,7 +388,10 @@ class SettingsActivity : AppCompatActivity() { val totalWeeks = Regex("\\d+").find(weeksDropdown.text.toString()) ?.value?.toIntOrNull() ?: 20 dialog.dismiss() - saveSemester(existing, name, selectedStartDate, totalWeeks) + val displayedStartDate = runCatching { + dateFormat.parse(dateInput.text?.toString().orEmpty())?.time + }.getOrNull() ?: selectedStartDate + saveSemester(existing, name, mondayStart(displayedStartDate), totalWeeks) } } dialog.show() @@ -292,6 +413,7 @@ class SettingsActivity : AppCompatActivity() { val courses = courseViewModel.getSemesterCourses(existing.id) manager.cancelAllReminders(courses) semesterViewModel.updateSemesterNow(updated) + semesters = semesters.map { if (it.id == updated.id) updated else it } if (existing.isCurrent && preferences.reminderEnabled) { manager.rescheduleReminders(courses, updated) } @@ -410,10 +532,28 @@ class SettingsActivity : AppCompatActivity() { MaterialAlertDialogBuilder(this) .setTitle(R.string.about_course_schedule) .setMessage(getString(R.string.about_message, versionName)) + .setNeutralButton(R.string.open_source_view_project) { _, _ -> openProjectRepository() } .setPositiveButton(R.string.ok, null) .show() } + private fun openProjectRepository() { + try { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.project_repository_url))) + .addCategory(Intent.CATEGORY_BROWSABLE)) + } catch (_: ActivityNotFoundException) { + // A device without a browser can still share/open the public address elsewhere. + copyProjectAddress() + } + } + + private fun copyProjectAddress() { + getSystemService(ClipboardManager::class.java).setPrimaryClip( + ClipData.newPlainText(getString(R.string.app_name), getString(R.string.project_repository_url)) + ) + Toast.makeText(this, R.string.open_source_address_copied, Toast.LENGTH_SHORT).show() + } + private fun mondayStart(timestamp: Long): Long { return Calendar.getInstance().apply { timeInMillis = timestamp @@ -437,11 +577,12 @@ class SettingsActivity : AppCompatActivity() { private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == android.R.id.home) { - finish() - return true + override fun onResume() { + super.onResume() + if (binding.bottomNavigation.selectedItemId != R.id.nav_settings) { + suppressBottomNavigationMotion = true + binding.bottomNavigation.selectedItemId = R.id.nav_settings + suppressBottomNavigationMotion = false } - return super.onOptionsItemSelected(item) } } diff --git a/app/src/main/java/com/courseschedule/utils/SchedulePreferences.kt b/app/src/main/java/com/courseschedule/utils/SchedulePreferences.kt index a2469d2..71bfaa2 100644 --- a/app/src/main/java/com/courseschedule/utils/SchedulePreferences.kt +++ b/app/src/main/java/com/courseschedule/utils/SchedulePreferences.kt @@ -25,6 +25,10 @@ class SchedulePreferences(context: Context) { get() = prefs.getBoolean(KEY_SHOW_TIME, true) set(value) = prefs.edit().putBoolean(KEY_SHOW_TIME, value).apply() + var showInactiveCourses: Boolean + get() = prefs.getBoolean(KEY_SHOW_INACTIVE_COURSES, true) + set(value) = prefs.edit().putBoolean(KEY_SHOW_INACTIVE_COURSES, value).apply() + var sectionHeightDp: Int get() = prefs.getInt(KEY_SECTION_HEIGHT, 64) set(value) = prefs.edit().putInt(KEY_SECTION_HEIGHT, value).apply() @@ -53,6 +57,7 @@ class SchedulePreferences(context: Context) { fun snapshot() = SettingsSnapshot( showWeekend = showWeekend, showTime = showTime, + showInactiveCourses = showInactiveCourses, sectionHeightDp = sectionHeightDp, reminderEnabled = reminderEnabled, defaultReminderMinutes = defaultReminderMinutes, @@ -66,6 +71,7 @@ class SchedulePreferences(context: Context) { prefs.edit() .putBoolean(KEY_SHOW_WEEKEND, snapshot.showWeekend) .putBoolean(KEY_SHOW_TIME, snapshot.showTime) + .putBoolean(KEY_SHOW_INACTIVE_COURSES, snapshot.showInactiveCourses) .putInt(KEY_SECTION_HEIGHT, snapshot.sectionHeightDp.coerceIn(56, 104)) .putBoolean(KEY_REMINDER_ENABLED, snapshot.reminderEnabled) .putInt(KEY_DEFAULT_REMINDER, snapshot.defaultReminderMinutes) @@ -78,6 +84,7 @@ class SchedulePreferences(context: Context) { const val PREFS_NAME = "settings" private const val KEY_SHOW_WEEKEND = "show_weekend" private const val KEY_SHOW_TIME = "show_time" + private const val KEY_SHOW_INACTIVE_COURSES = "show_inactive_courses" private const val KEY_SECTION_HEIGHT = "section_height_dp" private const val KEY_COMPACT_DENSITY_MIGRATED = "compact_density_migrated_v2" private const val KEY_REMINDER_ENABLED = "reminder_enabled" @@ -85,9 +92,9 @@ class SchedulePreferences(context: Context) { private const val KEY_SECTION_TIMES = "section_times" val DEFAULT_SECTION_TIMES = listOf( - "08:00", "08:55", "10:00", "10:55", - "14:00", "14:55", "16:00", "16:55", - "19:00", "19:55", "20:50", "21:45" + "08:00", "08:50", "09:50", "10:40", + "11:30", "14:30", "15:20", "16:20", + "17:10", "19:00", "19:50", "20:40" ) fun isValidTime(value: String): Boolean { diff --git a/app/src/main/java/com/courseschedule/view/CourseTableView.kt b/app/src/main/java/com/courseschedule/view/CourseTableView.kt index 16c3e4e..aa7f46e 100644 --- a/app/src/main/java/com/courseschedule/view/CourseTableView.kt +++ b/app/src/main/java/com/courseschedule/view/CourseTableView.kt @@ -1,7 +1,11 @@ package com.courseschedule.view +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator import android.content.Context import android.graphics.Canvas +import android.graphics.Color import android.graphics.Paint import android.graphics.Rect import android.graphics.RectF @@ -13,7 +17,10 @@ import android.util.AttributeSet import android.util.TypedValue import android.view.MotionEvent import android.view.View +import android.view.ViewConfiguration +import android.view.animation.PathInterpolator import androidx.core.content.ContextCompat +import androidx.core.graphics.ColorUtils import androidx.core.view.ViewCompat import androidx.core.view.accessibility.AccessibilityNodeInfoCompat import androidx.customview.widget.ExploreByTouchHelper @@ -21,7 +28,9 @@ import com.courseschedule.R import com.courseschedule.data.entity.Course import com.courseschedule.domain.ScheduleRules import com.courseschedule.utils.SchedulePreferences +import kotlin.math.abs import kotlin.math.roundToInt +import kotlin.math.sin /** * Weekly timetable canvas. Dimensions are density-aware so the grid stays @@ -35,13 +44,18 @@ class CourseTableView @JvmOverloads constructor( companion object { private const val TOTAL_SECTIONS = 12 + private const val PRESS_IN_DURATION_MS = 88L + private const val PRESS_OUT_DURATION_MS = 165L + private const val PRESSED_SCALE_X = 0.97f + private const val PRESSED_SCALE_Y = 0.985f + private val PRESS_INTERPOLATOR = PathInterpolator(0.2f, 0f, 0f, 1f) } private val density = resources.displayMetrics.density private var sectionHeight = dp(64f) private val timeColumnWidth = dp(48f) - private val courseInset = dp(2.5f) - private val courseCornerRadius = dp(6f) + private val courseInset = dp(3f) + private val courseCornerRadius = dp(10f) private val courseColors = intArrayOf( R.color.course_red, @@ -64,6 +78,7 @@ class CourseTableView @JvmOverloads constructor( private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = ContextCompat.getColor(context, R.color.surface_variant) + alpha = 105 style = Paint.Style.FILL } @@ -98,6 +113,13 @@ class CourseTableView @JvmOverloads constructor( style = Paint.Style.FILL } + private val courseStrokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + alpha = 190 + strokeWidth = dp(1.25f) + style = Paint.Style.STROKE + } + private val courseNamePaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { color = ContextCompat.getColor(context, R.color.on_primary) textSize = sp(12f) @@ -140,7 +162,21 @@ class CourseTableView @JvmOverloads constructor( private var visibleDaysCount = 7 private var showTimes = true private var highlightedDay: Int? = null - private var onCourseClickListener: ((Course) -> Unit)? = null + private var onCourseClickListener: ((Course, View, RectF) -> Unit)? = null + private var onEmptySlotClickListener: ((dayOfWeek: Int, section: Int) -> Unit)? = null + private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop + private var pressedCourse: Course? = null + private val coursePressMotion = CoursePressMotion() + private var pagerOffset = 0f + private var touchDownX = 0f + private var touchDownY = 0f + private var touchMoved = false + private var pressPivotX = 0f + private var pressPivotY = 0f + private var visibleCourseCache: List = emptyList() + private val courseBoundsCache = mutableMapOf() + private val courseTextLayoutCache = mutableMapOf() + private val cardDrawBounds = RectF() private var dayWidth = 0f private var totalWidth = 0f @@ -148,6 +184,64 @@ class CourseTableView @JvmOverloads constructor( private var sectionTimes = SchedulePreferences.DEFAULT_SECTION_TIMES + private data class CourseTextLayoutKey( + val course: Course, + val isCurrentWeek: Boolean + ) + + private data class CourseTextLayout( + val name: StaticLayout, + val room: StaticLayout?, + val teacher: StaticLayout?, + val nameGap: Float, + val detailGap: Float, + val contentHeight: Float + ) + + private inner class CoursePressMotion { + var progress = 0f + private set + private var animator: ValueAnimator? = null + private var generation = 0 + + fun animateTo(target: Float, duration: Long, onEnd: () -> Unit = {}) { + val currentGeneration = ++generation + animator?.cancel() + if (abs(progress - target) < 0.001f) { + progress = target + onEnd() + invalidate() + return + } + animator = ValueAnimator.ofFloat(progress, target).apply { + this.duration = duration + interpolator = PRESS_INTERPOLATOR + addUpdateListener { valueAnimator -> + progress = valueAnimator.animatedValue as Float + ViewCompat.postInvalidateOnAnimation(this@CourseTableView) + } + addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + if (generation != currentGeneration) return + progress = target + animator = null + onEnd() + invalidate() + } + }) + start() + } + } + + fun reset() { + generation++ + animator?.cancel() + animator = null + progress = 0f + invalidate() + } + } + private val accessibilityHelper = object : ExploreByTouchHelper(this) { override fun getVirtualViewAt(x: Float, y: Float): Int { return visibleCourses().indexOfFirst { course -> courseBounds(course).contains(x, y) } @@ -181,14 +275,14 @@ class CourseTableView @JvmOverloads constructor( ): Boolean { if (action != AccessibilityNodeInfoCompat.ACTION_CLICK) return false val course = visibleCourses().getOrNull(virtualViewId) ?: return false - onCourseClickListener?.invoke(course) + onCourseClickListener?.invoke(course, this@CourseTableView, RectF(courseBounds(course))) sendEventForVirtualView(virtualViewId, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_CLICKED) return true } } init { - setBackgroundColor(ContextCompat.getColor(context, R.color.surface)) + setBackgroundColor(Color.TRANSPARENT) importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES ViewCompat.setAccessibilityDelegate(this, accessibilityHelper) } @@ -196,7 +290,12 @@ class CourseTableView @JvmOverloads constructor( override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val desiredWidth = dp(360f).roundToInt() val measuredWidth = resolveSize(desiredWidth, widthMeasureSpec) - dayWidth = (measuredWidth - timeColumnWidth) / visibleDaysCount + val measuredDayWidth = (measuredWidth - timeColumnWidth) / visibleDaysCount + if (abs(dayWidth - measuredDayWidth) > 0.5f) { + courseBoundsCache.clear() + courseTextLayoutCache.clear() + } + dayWidth = measuredDayWidth totalWidth = measuredWidth.toFloat() totalHeight = TOTAL_SECTIONS * sectionHeight setMeasuredDimension(measuredWidth, totalHeight.roundToInt()) @@ -276,10 +375,60 @@ class CourseTableView @JvmOverloads constructor( } private fun drawCourses(canvas: Canvas) { - visibleCourses().forEach { course -> drawCourse(canvas, course) } + val visibleCourses = visibleCourses() + visibleCourses.forEach { course -> + val bounds = courseBounds(course) + val isCurrentWeek = ScheduleRules.isCourseInWeek(course, currentWeek) + val sectionSpan = (course.endSection - course.startSection + 1).coerceAtLeast(1) + val motionVariant = Math.floorMod( + course.dayOfWeek * 7 + course.startSection * 11 + course.endSection * 3, + 5 + ) + val arcDirection = if ((course.dayOfWeek + course.startSection) % 2 == 0) -1f else 1f + val motionDistance = abs(pagerOffset) + val travelDistance = dp( + 8f + motionVariant * 2.2f + sectionSpan.coerceAtMost(4) * 0.7f + ) + val saveCount = canvas.save() + + canvas.translate( + -pagerOffset * travelDistance, + sin(motionDistance * Math.PI).toFloat() * + arcDirection * dp(0.8f + motionVariant * 0.32f) + ) + + canvas.rotate( + -pagerOffset * arcDirection * (0.25f + motionVariant * 0.1f), + bounds.centerX(), + bounds.centerY() + ) + + val motionScale = 1f - motionDistance * + (0.006f + motionVariant * 0.0015f + sectionSpan.coerceAtMost(4) * 0.001f) + canvas.scale( + motionScale, + motionScale, + bounds.centerX(), + bounds.centerY() + ) + + val pressProgress = if (course == pressedCourse) coursePressMotion.progress else 0f + val motionAlpha = (255f * (1f - motionDistance * (0.08f + motionVariant * 0.008f))) + .roundToInt() + .coerceIn(0, 255) + val alpha = if (isCurrentWeek) motionAlpha else (motionAlpha * 0.46f).roundToInt() + drawCourse(canvas, course, alpha, isCurrentWeek, pressProgress) + canvas.restoreToCount(saveCount) + } } - private fun drawCourse(canvas: Canvas, course: Course) { + private fun drawCourse( + canvas: Canvas, + course: Course, + alpha: Int, + isCurrentWeek: Boolean, + pressProgress: Float + ) { val bounds = courseBounds(course) val left = bounds.left val top = bounds.top @@ -287,77 +436,144 @@ class CourseTableView @JvmOverloads constructor( val bottom = bounds.bottom if (right <= left || bottom <= top) return - coursePaint.color = courseColors[Math.floorMod(course.colorIndex, courseColors.size)] + val baseColor = courseColors[Math.floorMod(course.colorIndex, courseColors.size)] + coursePaint.color = ColorUtils.blendARGB(baseColor, Color.BLACK, 0.08f * pressProgress) + coursePaint.alpha = alpha + courseStrokePaint.alpha = ((190f + 40f * pressProgress) * alpha / 255f) + .roundToInt() + courseStrokePaint.strokeWidth = dp(1.25f + 0.4f * pressProgress) + val pressScaleX = 1f - (1f - PRESSED_SCALE_X) * pressProgress + val pressScaleY = 1f - (1f - PRESSED_SCALE_Y) * pressProgress + val pivotX = pressPivotX.coerceIn(left, right) + val pivotY = pressPivotY.coerceIn(top, bottom) + val pressTranslationY = dp(1.4f) * pressProgress + cardDrawBounds.set( + pivotX + (left - pivotX) * pressScaleX, + pivotY + (top - pivotY) * pressScaleY + pressTranslationY, + pivotX + (right - pivotX) * pressScaleX, + pivotY + (bottom - pivotY) * pressScaleY + pressTranslationY + ) canvas.drawRoundRect( - RectF(left, top, right, bottom), + cardDrawBounds, courseCornerRadius, courseCornerRadius, coursePaint ) - - val displayName = displayCourseName(course.courseName) - val nameLength = displayName.count { !it.isWhitespace() } - courseNamePaint.textSize = sp( - when { - nameLength >= 18 -> 8.5f - nameLength >= 12 -> 9.5f - else -> 11f - } + canvas.drawRoundRect( + cardDrawBounds, + courseCornerRadius, + courseCornerRadius, + courseStrokePaint ) - courseRoomPaint.textSize = sp(8.5f) - courseTeacherPaint.textSize = sp(8f) - val horizontalPadding = dp(3f) - val textWidth = (right - left - horizontalPadding * 2).roundToInt().coerceAtLeast(1) - val sectionCount = course.endSection - course.startSection + 1 - val showDetails = sectionCount >= 2 - val teacherLayout = if (showDetails && course.teacher.isNotBlank()) { - buildTextLayout( - resources.getString(R.string.course_teacher_inline, course.teacher), - courseTeacherPaint, - textWidth, - 1 - ) - } else { - null - } - val roomLayout = if (showDetails && course.classroom.isNotBlank()) { - buildTextLayout(course.classroom, courseRoomPaint, textWidth, 2) - } else { - null + val textLayout = courseTextLayoutCache.getOrPut( + CourseTextLayoutKey(course, isCurrentWeek) + ) { + buildCourseTextLayout(course, isCurrentWeek, bounds) } - val detailLayouts = listOfNotNull(teacherLayout, roomLayout) + textLayout.name.paint.alpha = alpha + textLayout.room?.paint?.alpha = 220 * alpha / 255 + textLayout.teacher?.paint?.alpha = 205 * alpha / 255 + val horizontalPadding = dp(6f) val verticalPadding = dp(5f) - val nameGap = if (detailLayouts.isNotEmpty()) dp(3f) else 0f - val detailGap = if (detailLayouts.size > 1) dp(1f) else 0f - val detailsHeight = detailLayouts.sumOf { it.height } + detailGap - val nameLineHeight = courseNamePaint.fontMetrics.run { descent - ascent } - val availableNameHeight = bottom - top - verticalPadding * 2 - - detailsHeight - nameGap - val nameLines = (availableNameHeight / nameLineHeight) - .toInt() - .coerceIn(1, 7) - val nameLayout = buildTextLayout(displayName, courseNamePaint, textWidth, nameLines) - - val contentHeight = nameLayout.height + detailsHeight + nameGap - var textTop = top + ((bottom - top - contentHeight) / 2f).coerceAtLeast(verticalPadding) + var textTop = top + ((bottom - top - textLayout.contentHeight) / 2f) + .coerceAtLeast(verticalPadding) + val normalizedTouchX = ((pressPivotX - bounds.centerX()) / bounds.width()) + .coerceIn(-0.5f, 0.5f) + val textTranslationX = dp(0.7f) * normalizedTouchX * pressProgress + val textTranslationY = dp(0.7f) * pressProgress canvas.save() - canvas.translate(left + horizontalPadding, textTop) - nameLayout.draw(canvas) + canvas.translate( + left + horizontalPadding + textTranslationX, + textTop + textTranslationY + ) + textLayout.name.draw(canvas) canvas.restore() - textTop += nameLayout.height + nameGap - detailLayouts.forEachIndexed { index, detailLayout -> + textTop += textLayout.name.height + textLayout.nameGap + textLayout.room?.let { roomLayout -> canvas.save() - canvas.translate(left + horizontalPadding, textTop) - detailLayout.draw(canvas) + canvas.translate( + left + horizontalPadding + textTranslationX, + textTop + textTranslationY + ) + roomLayout.draw(canvas) + canvas.restore() + textTop += roomLayout.height + if (textLayout.teacher != null) textTop += textLayout.detailGap + } + textLayout.teacher?.let { teacherLayout -> + canvas.save() + canvas.translate( + left + horizontalPadding + textTranslationX, + textTop + textTranslationY + ) + teacherLayout.draw(canvas) canvas.restore() - textTop += detailLayout.height - if (index < detailLayouts.lastIndex) textTop += detailGap } } + private fun buildCourseTextLayout( + course: Course, + isCurrentWeek: Boolean, + bounds: RectF + ): CourseTextLayout { + val courseName = displayCourseName(course.courseName) + val displayName = if (isCurrentWeek) { + courseName + } else { + resources.getString(R.string.inactive_course_name_format, courseName) + } + val namePaint = TextPaint(courseNamePaint).apply { + alpha = 255 + textSize = sp( + when { + displayName.count { !it.isWhitespace() } >= 18 -> 9f + displayName.count { !it.isWhitespace() } >= 12 -> 10f + else -> 11.5f + } + ) + } + val roomPaint = TextPaint(courseRoomPaint).apply { + alpha = 255 + textSize = sp(9f) + } + val teacherPaint = TextPaint(courseTeacherPaint).apply { + alpha = 255 + textSize = sp(8.5f) + } + val horizontalPadding = dp(6f) + val textWidth = (bounds.width() - horizontalPadding * 2).roundToInt().coerceAtLeast(1) + val showDetails = course.endSection - course.startSection + 1 >= 2 + val roomLayout = if (showDetails && course.classroom.isNotBlank()) { + buildTextLayout("@${course.classroom}", roomPaint, textWidth, 2) + } else { + null + } + val teacherLayout = if (showDetails && course.teacher.isNotBlank()) { + buildTextLayout(course.teacher, teacherPaint, textWidth, 1) + } else { + null + } + val detailCount = listOfNotNull(roomLayout, teacherLayout).size + val nameGap = if (detailCount > 0) dp(3f) else 0f + val detailGap = if (detailCount > 1) dp(1f) else 0f + val detailsHeight = (roomLayout?.height ?: 0) + (teacherLayout?.height ?: 0) + detailGap + val nameLineHeight = namePaint.fontMetrics.run { descent - ascent } + val availableNameHeight = bounds.height() - dp(10f) - detailsHeight - nameGap + val nameLines = (availableNameHeight / nameLineHeight).toInt().coerceIn(1, 7) + val nameLayout = buildTextLayout(displayName, namePaint, textWidth, nameLines) + return CourseTextLayout( + name = nameLayout, + room = roomLayout, + teacher = teacherLayout, + nameGap = nameGap, + detailGap = detailGap, + contentHeight = nameLayout.height + detailsHeight + nameGap + ) + } + private fun buildTextLayout( text: String, paint: TextPaint, @@ -365,7 +581,7 @@ class CourseTableView @JvmOverloads constructor( maxLines: Int ): StaticLayout = StaticLayout.Builder .obtain(text, 0, text.length, paint, width) - .setAlignment(Layout.Alignment.ALIGN_CENTER) + .setAlignment(Layout.Alignment.ALIGN_NORMAL) .setEllipsize(TextUtils.TruncateAt.END) .setIncludePad(false) .setLineSpacing(0f, 1f) @@ -373,21 +589,92 @@ class CourseTableView @JvmOverloads constructor( .build() override fun onTouchEvent(event: MotionEvent): Boolean { - if (event.action == MotionEvent.ACTION_UP && event.x > timeColumnWidth) { - val day = ((event.x - timeColumnWidth) / dayWidth).toInt() + 1 - val section = (event.y / sectionHeight).toInt() + 1 - if (day > visibleDaysCount) return true - visibleCourses().firstOrNull { course -> - course.dayOfWeek == day && - section in course.startSection..course.endSection - }?.let { course -> - performClick() - onCourseClickListener?.invoke(course) + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + resetCoursePress() + touchDownX = event.x + touchDownY = event.y + touchMoved = false + pressedCourse = courseAt(event.x, event.y) + if (pressedCourse != null) { + pressPivotX = event.x + pressPivotY = event.y + coursePressMotion.animateTo(1f, PRESS_IN_DURATION_MS) + } + } + MotionEvent.ACTION_MOVE -> { + val course = pressedCourse + val movedPastSlop = abs(event.x - touchDownX) > touchSlop || + abs(event.y - touchDownY) > touchSlop + val bounds = course?.let(::courseBounds) + val outsideCourse = bounds != null && ( + event.x < bounds.left - touchSlop || + event.x > bounds.right + touchSlop || + event.y < bounds.top - touchSlop || + event.y > bounds.bottom + touchSlop + ) + if (!touchMoved && movedPastSlop) touchMoved = true + if (course != null && (movedPastSlop || outsideCourse)) { + touchMoved = true + releaseCoursePress() + } + } + MotionEvent.ACTION_UP -> { + val course = pressedCourse + val isClick = !touchMoved && course != null && courseBounds(course).contains( + event.x, + event.y + ) + releaseCoursePress() + if (isClick && course != null) { + val sourceBounds = RectF(courseBounds(course)) + performClick() + onCourseClickListener?.invoke(course, this, sourceBounds) + } else if (!touchMoved && course == null) { + emptySlotAt(event.x, event.y)?.let { (day, section) -> + performClick() + onEmptySlotClickListener?.invoke(day, section) + } + } + } + MotionEvent.ACTION_CANCEL -> { + if (!touchMoved) { + touchMoved = true + releaseCoursePress() + } } } return true } + private fun courseAt(x: Float, y: Float): Course? { + if (x <= timeColumnWidth) return null + return visibleCourses().firstOrNull { course -> + courseBounds(course).contains(x, y) + } + } + + private fun emptySlotAt(x: Float, y: Float): Pair? { + if (x <= timeColumnWidth || x >= totalWidth || y < 0f || y >= totalHeight || dayWidth <= 0f) { + return null + } + val day = ((x - timeColumnWidth) / dayWidth).toInt() + 1 + val section = (y / sectionHeight).toInt() + 1 + return (day to section).takeIf { day in 1..visibleDaysCount && section in 1..TOTAL_SECTIONS } + } + + private fun resetCoursePress() { + coursePressMotion.reset() + pressedCourse = null + } + + private fun releaseCoursePress() { + val course = pressedCourse ?: return + coursePressMotion.animateTo(0f, PRESS_OUT_DURATION_MS) { + if (pressedCourse == course) pressedCourse = null + } + } + override fun performClick(): Boolean { super.performClick() return true @@ -397,14 +684,37 @@ class CourseTableView @JvmOverloads constructor( return accessibilityHelper.dispatchHoverEvent(event) || super.dispatchHoverEvent(event) } + override fun onDetachedFromWindow() { + resetCoursePress() + super.onDetachedFromWindow() + } + fun setCourses(courses: List) { + resetCoursePress() this.courses = courses + rebuildVisibleCourses() + clearCourseRenderCaches() accessibilityHelper.invalidateRoot() invalidate() } + fun setPagerOffset(position: Float) { + val newOffset = position.coerceIn(-1f, 1f) + if (abs(pagerOffset - newOffset) < 0.001f) return + pagerOffset = newOffset + ViewCompat.postInvalidateOnAnimation(this) + } + + fun resetPagerMotion() { + pagerOffset = 0f + invalidate() + } + fun setCurrentWeek(week: Int) { + if (currentWeek == week) return currentWeek = week + rebuildVisibleCourses() + courseTextLayoutCache.clear() accessibilityHelper.invalidateRoot() invalidate() } @@ -425,25 +735,40 @@ class CourseTableView @JvmOverloads constructor( this.sectionTimes = sectionTimes.takeIf { it.size == TOTAL_SECTIONS } ?: SchedulePreferences.DEFAULT_SECTION_TIMES sectionHeight = dp(sectionHeightDp.coerceIn(56, 104).toFloat()) + rebuildVisibleCourses() + clearCourseRenderCaches() accessibilityHelper.invalidateRoot() requestLayout() invalidate() } - fun setOnCourseClickListener(listener: (Course) -> Unit) { + fun setOnCourseClickListener(listener: (Course, View, RectF) -> Unit) { onCourseClickListener = listener } - private fun visibleCourses(): List = courses.filter { - it.dayOfWeek <= visibleDaysCount && ScheduleRules.isCourseInWeek(it, currentWeek) + fun setOnEmptySlotClickListener(listener: (dayOfWeek: Int, section: Int) -> Unit) { + onEmptySlotClickListener = listener } - private fun courseBounds(course: Course): RectF = RectF( - timeColumnWidth + (course.dayOfWeek - 1) * dayWidth + courseInset, - (course.startSection - 1) * sectionHeight + courseInset, - timeColumnWidth + course.dayOfWeek * dayWidth - courseInset, - course.endSection * sectionHeight - courseInset - ) + private fun rebuildVisibleCourses() { + visibleCourseCache = courses.filter { it.dayOfWeek <= visibleDaysCount } + } + + private fun clearCourseRenderCaches() { + courseBoundsCache.clear() + courseTextLayoutCache.clear() + } + + private fun visibleCourses(): List = visibleCourseCache + + private fun courseBounds(course: Course): RectF = courseBoundsCache.getOrPut(course) { + RectF( + timeColumnWidth + (course.dayOfWeek - 1) * dayWidth + courseInset, + (course.startSection - 1) * sectionHeight + courseInset, + timeColumnWidth + course.dayOfWeek * dayWidth - courseInset, + course.endSection * sectionHeight - courseInset + ) + } private fun courseDescription(course: Course): String { val days = resources.getStringArray(R.array.weekdays) @@ -452,7 +777,7 @@ class CourseTableView @JvmOverloads constructor( 2 -> resources.getString(R.string.even_week) else -> resources.getString(R.string.every_week) } - return resources.getString( + val description = resources.getString( R.string.course_accessibility_description, displayCourseName(course.courseName), days.getOrElse(course.dayOfWeek - 1) { "" }, @@ -464,6 +789,11 @@ class CourseTableView @JvmOverloads constructor( course.teacher.ifBlank { resources.getString(R.string.not_set) }, course.classroom.ifBlank { resources.getString(R.string.not_set) } ) + return if (ScheduleRules.isCourseInWeek(course, currentWeek)) { + description + } else { + "${resources.getString(R.string.inactive_course_label)},$description" + } } private fun dp(value: Float): Float = value * density diff --git a/app/src/main/res/drawable/bg_bottom_navigation.xml b/app/src/main/res/drawable/bg_bottom_navigation.xml new file mode 100644 index 0000000..1e75205 --- /dev/null +++ b/app/src/main/res/drawable/bg_bottom_navigation.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_course_detail_sheet.xml b/app/src/main/res/drawable/bg_course_detail_sheet.xml new file mode 100644 index 0000000..9d8ab74 --- /dev/null +++ b/app/src/main/res/drawable/bg_course_detail_sheet.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_day_selected.xml b/app/src/main/res/drawable/bg_day_selected.xml index 40522ec..7d96b56 100644 --- a/app/src/main/res/drawable/bg_day_selected.xml +++ b/app/src/main/res/drawable/bg_day_selected.xml @@ -7,5 +7,6 @@ + diff --git a/app/src/main/res/drawable/bg_import_school_icon.xml b/app/src/main/res/drawable/bg_import_school_icon.xml new file mode 100644 index 0000000..71afaff --- /dev/null +++ b/app/src/main/res/drawable/bg_import_school_icon.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_schedule_screen.xml b/app/src/main/res/drawable/bg_schedule_screen.xml new file mode 100644 index 0000000..fc20f7e --- /dev/null +++ b/app/src/main/res/drawable/bg_schedule_screen.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/drawable/bg_sheet_handle.xml b/app/src/main/res/drawable/bg_sheet_handle.xml new file mode 100644 index 0000000..b9c6a05 --- /dev/null +++ b/app/src/main/res/drawable/bg_sheet_handle.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_week_selector.xml b/app/src/main/res/drawable/bg_week_selector.xml new file mode 100644 index 0000000..da4f207 --- /dev/null +++ b/app/src/main/res/drawable/bg_week_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml index 5fb6255..c700974 100644 --- a/app/src/main/res/drawable/ic_add.xml +++ b/app/src/main/res/drawable/ic_add.xml @@ -5,6 +5,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml new file mode 100644 index 0000000..39d4726 --- /dev/null +++ b/app/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_person.xml b/app/src/main/res/drawable/ic_person.xml new file mode 100644 index 0000000..433686a --- /dev/null +++ b/app/src/main/res/drawable/ic_person.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_room.xml b/app/src/main/res/drawable/ic_room.xml new file mode 100644 index 0000000..b043959 --- /dev/null +++ b/app/src/main/res/drawable/ic_room.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_time.xml b/app/src/main/res/drawable/ic_time.xml new file mode 100644 index 0000000..a466645 --- /dev/null +++ b/app/src/main/res/drawable/ic_time.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/action_duck_mascot.xml b/app/src/main/res/layout/action_duck_mascot.xml new file mode 100644 index 0000000..0471dd8 --- /dev/null +++ b/app/src/main/res/layout/action_duck_mascot.xml @@ -0,0 +1,6 @@ + + diff --git a/app/src/main/res/layout/activity_academic_web_import.xml b/app/src/main/res/layout/activity_academic_web_import.xml new file mode 100644 index 0000000..74cce28 --- /dev/null +++ b/app/src/main/res/layout/activity_academic_web_import.xml @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_import.xml b/app/src/main/res/layout/activity_import.xml index 880e971..1c3c2d9 100644 --- a/app/src/main/res/layout/activity_import.xml +++ b/app/src/main/res/layout/activity_import.xml @@ -9,67 +9,138 @@ - - + app:layout_constraintTop_toBottomOf="@id/toolbar"> + android:paddingHorizontal="16dp" + android:paddingTop="18dp"> + + + + + + + + + + + + + + + + + + + + + + + android:textSize="12sp" /> + + + app:layout_constraintTop_toTopOf="parent"> + + + + + @@ -33,11 +64,12 @@ @@ -46,17 +78,17 @@ android:id="@+id/btnPreviousWeek" android:layout_width="40dp" android:layout_height="40dp" - android:background="@drawable/bg_icon_button" + android:background="?attr/selectableItemBackgroundBorderless" android:contentDescription="@string/previous_week" android:padding="8dp" android:src="@drawable/ic_chevron_left" - app:tint="@color/text_primary" /> + app:tint="@color/text_secondary" /> + app:tint="@color/text_secondary" /> + app:trackColor="#42FFFFFF" + app:trackThickness="2dp" /> - - diff --git a/app/src/main/res/layout/activity_school_picker.xml b/app/src/main/res/layout/activity_school_picker.xml new file mode 100644 index 0000000..10653aa --- /dev/null +++ b/app/src/main/res/layout/activity_school_picker.xml @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 693881b..4270bee 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -10,11 +10,11 @@ + + + + + + + + + + + + - + - + + + + + + + + - + + + diff --git a/app/src/main/res/layout/dialog_academic_address.xml b/app/src/main/res/layout/dialog_academic_address.xml new file mode 100644 index 0000000..7aeffac --- /dev/null +++ b/app/src/main/res/layout/dialog_academic_address.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_course_details.xml b/app/src/main/res/layout/dialog_course_details.xml index 458b8a3..f505fa4 100644 --- a/app/src/main/res/layout/dialog_course_details.xml +++ b/app/src/main/res/layout/dialog_course_details.xml @@ -1,138 +1,181 @@ + android:paddingBottom="32dp"> + + - - + android:textSize="22sp" + android:textStyle="bold" /> + + - - - + + android:textSize="16sp" /> - + + android:textSize="16sp" /> - + + + android:layout_marginStart="22dp" + android:layout_weight="1" + android:orientation="vertical"> + + + + + + + + + + + android:textIsSelectable="true" + android:textSize="16sp" /> - + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_week_schedule.xml b/app/src/main/res/layout/item_week_schedule.xml index f1a4b39..1863b09 100644 --- a/app/src/main/res/layout/item_week_schedule.xml +++ b/app/src/main/res/layout/item_week_schedule.xml @@ -4,26 +4,28 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="@color/surface" + android:background="@android:color/transparent" android:orientation="vertical"> + android:text="8 月" + android:textColor="@color/text_secondary" + android:textSize="11sp" + android:textStyle="bold" /> @@ -38,7 +40,7 @@ android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" - android:background="@color/surface"> + android:background="@android:color/transparent"> + android:id="@+id/emptyIconContainer" + android:layout_width="72dp" + android:layout_height="72dp" + android:background="@drawable/bg_empty_icon" + android:contentDescription="@string/no_courses_this_week"> + + diff --git a/app/src/main/res/raw/course_duck.riv b/app/src/main/res/raw/course_duck.riv new file mode 100644 index 0000000..c15b8a3 Binary files /dev/null and b/app/src/main/res/raw/course_duck.riv differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 6c29310..56410b0 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,44 +1,45 @@ - #176B5B - #0F4F43 - #DDEFEA - #D29436 - #A66D1F + #3E628C + #294967 + #DCE8F5 + #D69043 + #A96928 - #F4F7F6 - #FFFFFF - #F8FAF9 + #EDF2FA + #FAFCFF + #F3F6FB + #EBF4F7FC #B3261E #FFFFFF #2B1B00 - #1D2422 - #1D2422 - #1D2422 - #66716E - #8C9693 + #1C2733 + #1C2733 + #1C2733 + #667487 + #8A96A5 - #C65D57 - #B7567A - #8062B5 - #6555A3 - #586FB3 - #397EAE - #398CA6 - #368C8A - #397E70 - #4A855E - #718B45 - #8A913E - #B4882F - #B8772F - #B9673E - #8A6859 + #E78387 + #DD86A8 + #A58BE2 + #8F82D3 + #819DDB + #68A6DA + #73B4DE + #70C2C7 + #68B8A8 + #7BB38C + #9CB97B + #B0BE73 + #CDB36C + #DAA56E + #E28C70 + #B39182 @@ -61,14 +62,18 @@ - #E3E9E7 - #E8ECEB - #DDEFEA - #EDF7F4 - #FAFCFB - #F6F9F8 - #176B5B + #DCE4EF + #DCE4EF + #DCE8F5 + #2ADCE8F5 + #1AFFFFFF + #24D9E5F5 + #3E628C + #18A995 + #E19A3D + #4F84C4 + #D65D64 - #176B5B + #3E628C diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6d7ed55..a8fce11 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -90,6 +90,113 @@ 导入课程 选择导入方式 将导入到 %1$s + 从教务系统导入 + 搜索学校 · 自动匹配教务入口和系统类型 + 选择学校 + 搜索学校名称或教务域名 + 全部 + 专科及本科 + 研究生 + 通用教务 + 已收录 %1$d 条学校 / 教务入口配置 · 右侧显示适配状态 + 找到 %1$d 所学校 + 正在加载完整学校目录… + 没有找到这所学校\n可提交学校名称申请适配 + 没有我的学校?提交适配申请 + https://github.com/cakeni/CourseSchedule/issues/new + 无法打开项目页面 + 该学校入口配置无效,请提交更新 + %1$s · %2$s + %1$s · HTTP 明文 · %2$s + 已验证 + 通用适配 + 实验识别 + 需填写网址 + 需专用适配 + 待验证 + %1$s,%2$s,%3$s + 专用教务适配器 + 填写学校教务网址后试用 + 没有可独立访问的网址 + 这所学校还需专用适配器 + %1$s 已在完整学校目录中,但其现有流程依赖专用或第三方私有适配器,当前不能安全地用通用网页解析代替。可以提交学校名称和脱敏页面样本帮助适配。 + 尚未找到可用的本地入口 + %1$s 在上游目录中依赖云端解析;本应用不会上传课表页面,目前也尚未核实可由本地解析的公网入口。可以提交学校名称和脱敏页面样本,帮助开发本地适配器。 + 操作指引:%1$s + 提交适配信息 + 学校只提供明文 HTTP + %1$s 的目录入口是 http://%2$s。此连接没有 TLS 加密,网络中的其他设备可能看到或篡改页面内容;后续 HTTP 跳转也存在相同风险。如果学校另有 HTTPS / WebVPN,请优先使用安全入口。 + 了解风险,继续 + 安全提醒:当前学校入口使用明文 HTTP;请优先寻找学校 HTTPS / WebVPN。\n\n%1$s + 找不到学校?通用教务导入 + 选择系统,填写学校教务网址 + 通用教务导入 + %1$s · 填写教务网址 + 按教务厂商和页面格式导入,不需要把每所学校逐个写死。请选择最接近的系统版本;不同学校的二次开发仍需在导入预览中核对。 + 教务系统类型 + 强智 · 学期理论课表 + 金智 / Wisedu · jwapp / gsapp + 学校教务网址 + 例如 https://jw.example.edu.cn/ + 请从学校官网确认网址,优先填写 HTTPS 教务入口而非统一认证入口。HTTP 地址会再次提示风险;账号密码只在学校网页输入。 + WakeUp 目录记录的参考地址可能已失效或只是 VPN 门户,请从学校官网核对后填写:\n%1$s + 页面要求:%1$s\n解析器只接受明确的星期、节次和周次,不会用默认值补齐缺失课程。 + %1$s · 通用协议试用\n%2$s\n读取后请在导入预览中核对课程;无法确认的字段会中止导入。 + 打开包含完整星期、节次和周次的学期课表后读取。 + 确认网址并打开 + 通用强智 · 试用\n登录后进入「我的课表 → 学期理论课表」并查询完整学期。同一厂商的不同版本可能不兼容,请核对导入预览。 + 通用金智 · 试用\n登录后打开学生课表。会依次尝试 wdkb、xkjglapp、gsapp 和 homeapp 常见接口;学校二次开发仍可能不同,请核对导入预览。 + 打开外部链接? + 网页想要交给其他应用打开:\n%1$s\n\n请确认你信任此链接。 + 继续打开 + 链接格式无效,无法打开 + 设备上没有可处理此链接的应用 + 返回教务入口 + 金智 / Wisedu · 已有用户验证 + 强智 · 试用适配,待本校验证 + 从课表 HTML 文件导入 + %1$s教务系统 + 试用适配 · 待本校验证\n请先登录学校 WebVPN,再点「打开教务」,进入「我的课表 → 学期理论课表」并查询学期。不会修改当前作息或开学日期。 + 试用适配 · 待本校验证\n点击网页中的「统一身份认证登录」,完成学校登录后进入「我的课表」,打开完整学期课表再读取。 + 试用适配 · 待本校验证\n完成学校登录后进入「培养管理 → 学生课表查询」,选择学期并查询完整课表后读取。 + 打开教务 + 读取页面所选学期课表 + 请完成登录,打开学期课表后读取 + 页面加载失败,请检查校园网 / WebVPN 后重试 + 读取超时,请检查网络并确认已登录,再重试。 + 未找到可读取的强智课表。请登录 WebVPN,点击「打开教务」,进入「我的课表 → 学期理论课表」并查询。也可返回后从课表 HTML 文件导入。 + 课表位于暂时无法读取的网页框架中。请将学期课表在当前窗口打开后重试,或返回后使用课表 HTML 文件导入。 + 未找到所选系统可读取的完整课表。请按页面提示进入学期课表并等待加载;若学校改过页面格式,可返回后导入 HTML 文件并反馈脱敏样本。 + 项目公告 · CourseSchedule 已开源 + 采用 MIT 许可证。点击查看源码、反馈问题或参与贡献;长按可复制项目地址。 + https://github.com/cakeni/CourseSchedule + github.com/cakeni/CourseSchedule + 打开 CourseSchedule 开源项目 + 查看开源项目 + 项目地址已复制 + 其他方式 + 教务系统导入 + 等待登录 + 账号和密码只提交给学校网页,本应用不会保存 + 正在识别学期 + 正在获取课表 + 正在解析课程 + 已获取 %1$d 门课程 + 读取当前学期课表 + 学校网站证书校验失败,已停止加载 + 学校网站证书异常,等待确认 + 学校网站证书异常 + %1$s 的证书无法通过系统校验。只有确认这是学校官方地址时才继续;本次导入期间不再重复询问该域名。 + 仅本次继续 + 教务系统返回的数据过大,无法安全导入 + 读取课表失败 + 请确认已经登录教务系统,并且当前学期课表可以正常查看。 + 查看诊断 + 脱敏诊断预览 + 仅包含结构、字段类型和安全化路径;请确认后再手动保存。不会包含账号、Cookie、表单、链接或原始课程文本。 + 导出 JSON + 诊断已保存 + 诊断保存失败 从文件导入 从文本导入 Excel(.xlsx)、JSON、CSV、HTML 和文本 @@ -111,7 +218,7 @@ 可导入 %1$d 门 冲突 %2$d 门\n重复 %3$d 门 无效 %4$d 门 信息完整度:地点 %1$d/%2$d 教师 %3$d/%2$d 仍然导入时间冲突的课程 - 同时恢复学期信息和应用设置 + 同时恢复备份中的学期信息和设置(会覆盖第 1 周日期) 清空当前学期后导入(可撤销) 确认导入 没有选中可导入的课程 @@ -133,7 +240,7 @@ 编辑学期 删除学期 学期名称 - 开学日期 + 第 1 周周一 总周数 当前学期 设为当前学期 @@ -160,6 +267,7 @@ 浏览第%d周 %1$s · %2$s-%3$s %1$s · %2$s-%3$s · %4$d门课 + 第%1$d周 · %2$s 学期中 假期 · 浏览第%d周 未开学 · 浏览第%d周 @@ -183,6 +291,9 @@ 课表显示 显示周末 + 显示非本周课程 + 非本周 + [非本周]\n%1$s 显示节次时间 课程块高度 %d dp diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 11cd9a8..20babc6 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -24,10 +24,12 @@ @color/on_primary - @color/surface - @color/surface + @color/background + @color/navigation_surface true sans + false + @color/background + + + + + + + +