wake_up.js 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829
  1. // 拾光课程表适配 Wakeup 课表分享口令
  2. // 目前采用v6.1.70 官渠apk中 提取到的apk签名md5与signA算法
  3. // signA二次发送至antispam 取signB
  4. const WAKEUP_V6170 = Object.freeze({
  5. host: "https://api.wakeup.fun",
  6. antispamPath: "/pluto/app/antispam",
  7. sharePath: "/share_schedule/getv2",
  8. packageName: "com.suda.yzune.wakeupschedule",
  9. versionName: "6.1.70",
  10. versionCode: 450,
  11. channel: "100271a",
  12. publicToken: "1_XPXQH3c5HRPtFHkSwi3sCCURmT25QfxM",
  13. signatureMd5: "318c6d4f74655d4f032fb0466bcfdfbc",
  14. magic: "8&%d*",
  15. signAKey: "@fG2SuLA",
  16. keySalt: "@#AIjd83#@6B"
  17. });
  18. const WAKEUP_DEVICE_DEFAULTS = Object.freeze({
  19. androidId: "0000000000000000",
  20. sdk: "35",
  21. device: "Pixel 7",
  22. brand: "google",
  23. screensize: "1080x2400",
  24. abis: "arm64-v8a",
  25. appBit: "64",
  26. appId: "wakeup",
  27. downloadType: "1",
  28. nt: "wifi",
  29. province: "",
  30. city: "",
  31. area: "",
  32. deviceId: "",
  33. operatorid: "",
  34. adid: "",
  35. did: ""
  36. });
  37. /**
  38. * 验证用户输入。WakeUP v6.1.70 的分享口令可包含字母、数字、下划线和短横线。
  39. * @param {string} input 用户输入的原始文本
  40. * @returns {false|string} 验证成功返回 false,否则返回错误信息。
  41. */
  42. function validateKey(input) {
  43. const key = extractKeyFromText(input);
  44. if (!key) return "输入不能为空!";
  45. if (!/^[A-Za-z0-9_-]{8,200}$/.test(key)) {
  46. return "未检测到有效的 WakeUP 分享口令,请粘贴完整分享文本或口令。";
  47. }
  48. return false;
  49. }
  50. /**
  51. * 节次与周次合并去重函数
  52. * @param {Array<Object>} courses 原始解析课程数组
  53. * @returns {Array<Object>} 合并去重后的课程数组
  54. */
  55. function mergeAndDistinctCourses(courses) {
  56. if (!Array.isArray(courses) || courses.length <= 1) return courses;
  57. // 1. 深拷贝并规范周次数据,过滤无效项
  58. const list = courses.map(c => ({
  59. ...c,
  60. name: c.name || '',
  61. teacher: c.teacher || '',
  62. position: c.position || '',
  63. weeks: Array.isArray(c.weeks) ? [...c.weeks].sort((a, b) => a - b) : []
  64. }));
  65. // 阶段 1:合并连续节次与完全重复记录
  66. list.sort((a, b) => {
  67. return a.name.localeCompare(b.name) ||
  68. a.teacher.localeCompare(b.teacher) ||
  69. a.position.localeCompare(b.position) ||
  70. (a.day || 0) - (b.day || 0) ||
  71. a.weeks.join(',').localeCompare(b.weeks.join(',')) ||
  72. (a.startSection || 0) - (b.startSection || 0);
  73. });
  74. const step1Merged = [];
  75. let current = list[0];
  76. for (let i = 1; i < list.length; i++) {
  77. const next = list[i];
  78. const isSameCourseAndWeeks =
  79. current.name === next.name &&
  80. current.teacher === next.teacher &&
  81. current.position === next.position &&
  82. current.day === next.day &&
  83. current.weeks.join(',') === next.weeks.join(',');
  84. const isContinuous = current.endSection + 1 === next.startSection;
  85. const isDuplicate = current.startSection === next.startSection && current.endSection === next.endSection;
  86. if (isSameCourseAndWeeks && isContinuous) {
  87. // 节次连续:延长结束节次
  88. current.endSection = next.endSection;
  89. } else if (isSameCourseAndWeeks && isDuplicate) {
  90. // 完全重复:跳过
  91. continue;
  92. } else {
  93. step1Merged.push(current);
  94. current = next;
  95. }
  96. }
  97. step1Merged.push(current);
  98. // 阶段 2:合并同节次的周次
  99. step1Merged.sort((a, b) => {
  100. return a.name.localeCompare(b.name) ||
  101. a.teacher.localeCompare(b.teacher) ||
  102. a.position.localeCompare(b.position) ||
  103. (a.day || 0) - (b.day || 0) ||
  104. (a.startSection || 0) - (b.startSection || 0) ||
  105. (a.endSection || 0) - (b.endSection || 0);
  106. });
  107. const step2Merged = [];
  108. let cur = step1Merged[0];
  109. for (let i = 1; i < step1Merged.length; i++) {
  110. const nxt = step1Merged[i];
  111. const isSameCourseAndSection =
  112. cur.name === nxt.name &&
  113. cur.teacher === nxt.teacher &&
  114. cur.position === nxt.position &&
  115. cur.day === nxt.day &&
  116. cur.startSection === nxt.startSection &&
  117. cur.endSection === nxt.endSection;
  118. if (isSameCourseAndSection) {
  119. // 周次合并去重
  120. cur.weeks = Array.from(new Set([...cur.weeks, ...nxt.weeks])).sort((a, b) => a - b);
  121. } else {
  122. step2Merged.push(cur);
  123. cur = nxt;
  124. }
  125. }
  126. step2Merged.push(cur);
  127. return step2Merged;
  128. }
  129. /**
  130. * 从 WakeUP 的标准分享文案或裸口令中提取分享口令。
  131. * 规则与 WakeUP v6.1.70 官方渠道一致,兼容旧版 32 位十六进制口令。
  132. */
  133. function extractKeyFromText(value) {
  134. const text = typeof value === "string" ? value.trim() : "";
  135. if (!text) return "";
  136. const labelled = text.match(/分享口令(?:为)?\s*[「“"]?\s*([A-Za-z0-9_-]{1,200})\s*[」”"]?/u);
  137. if (labelled) return labelled[1];
  138. const quoted = text.match(/[「“"]\s*([A-Za-z0-9_-]{8,200})\s*[」”"]/u);
  139. if (quoted) return quoted[1];
  140. const candidates = text.match(/[A-Za-z0-9_-]{8,200}/g);
  141. return candidates ? candidates[candidates.length - 1] : text;
  142. }
  143. /** WakeUP 协议的浏览器字节、Base64 与 Android quote_plus 兼容工具。 */
  144. function wakeupToBytes(input) {
  145. return typeof input === "string" ? new TextEncoder().encode(input) : new Uint8Array(input);
  146. }
  147. function wakeupBytesToBinary(bytes) {
  148. const source = wakeupToBytes(bytes);
  149. let output = "";
  150. const chunkSize = 0x8000;
  151. for (let index = 0; index < source.length; index += chunkSize) {
  152. output += String.fromCharCode.apply(null, source.subarray(index, index + chunkSize));
  153. }
  154. return output;
  155. }
  156. function wakeupBytesToBase64(bytes) {
  157. return btoa(wakeupBytesToBinary(bytes));
  158. }
  159. function wakeupBase64ToBytes(text) {
  160. const binary = atob(text);
  161. const output = new Uint8Array(binary.length);
  162. for (let index = 0; index < binary.length; index += 1) output[index] = binary.charCodeAt(index);
  163. return output;
  164. }
  165. function wakeupBytesToLatin1(bytes) {
  166. return wakeupBytesToBinary(bytes);
  167. }
  168. function wakeupBytesToUtf8(bytes) {
  169. return new TextDecoder("utf-8").decode(wakeupToBytes(bytes));
  170. }
  171. function wakeupAndroidQuote(value) {
  172. return encodeURIComponent(String(value))
  173. .replace(/[!'()*]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)
  174. .replace(/%20/g, "+");
  175. }
  176. function wakeupFormEncode(items) {
  177. return items.map(([key, value]) => `${key}=${wakeupAndroidQuote(value == null ? "" : value)}`).join("&");
  178. }
  179. function wakeupRotateLeft(value, bits) {
  180. return ((value << bits) | (value >>> (32 - bits))) >>> 0;
  181. }
  182. /**
  183. * 纯 JavaScript MD5。Web Crypto 不提供 MD5,故保持适配脚本不依赖 Node 或第三方库。
  184. * 输入按 UTF-8 字节计算,与 WakeUP 官方协议中的 native/Java 实现对齐。
  185. */
  186. function wakeupMd5Bytes(input) {
  187. const source = wakeupToBytes(input);
  188. const paddedLength = (((source.length + 8) >>> 6) + 1) * 64;
  189. const data = new Uint8Array(paddedLength);
  190. data.set(source);
  191. data[source.length] = 0x80;
  192. const bitLength = source.length * 8;
  193. for (let index = 0; index < 8; index += 1) {
  194. data[paddedLength - 8 + index] = Math.floor(bitLength / Math.pow(256, index)) & 0xff;
  195. }
  196. const shifts = [7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
  197. 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
  198. 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
  199. 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21];
  200. const constants = Array.from({ length: 64 }, (_, index) => Math.floor(Math.abs(Math.sin(index + 1)) * 0x100000000) >>> 0);
  201. let a0 = 0x67452301;
  202. let b0 = 0xefcdab89;
  203. let c0 = 0x98badcfe;
  204. let d0 = 0x10325476;
  205. for (let offset = 0; offset < data.length; offset += 64) {
  206. const words = new Uint32Array(16);
  207. for (let index = 0; index < 16; index += 1) {
  208. const wordOffset = offset + index * 4;
  209. words[index] = (data[wordOffset] | (data[wordOffset + 1] << 8) |
  210. (data[wordOffset + 2] << 16) | (data[wordOffset + 3] << 24)) >>> 0;
  211. }
  212. let a = a0;
  213. let b = b0;
  214. let c = c0;
  215. let d = d0;
  216. for (let index = 0; index < 64; index += 1) {
  217. let f;
  218. let g;
  219. if (index < 16) {
  220. f = (b & c) | ((~b) & d);
  221. g = index;
  222. } else if (index < 32) {
  223. f = (d & b) | ((~d) & c);
  224. g = (5 * index + 1) % 16;
  225. } else if (index < 48) {
  226. f = b ^ c ^ d;
  227. g = (3 * index + 5) % 16;
  228. } else {
  229. f = c ^ (b | (~d));
  230. g = (7 * index) % 16;
  231. }
  232. const next = d;
  233. d = c;
  234. c = b;
  235. b = (b + wakeupRotateLeft((a + f + constants[index] + words[g]) >>> 0, shifts[index])) >>> 0;
  236. a = next;
  237. }
  238. a0 = (a0 + a) >>> 0;
  239. b0 = (b0 + b) >>> 0;
  240. c0 = (c0 + c) >>> 0;
  241. d0 = (d0 + d) >>> 0;
  242. }
  243. const digest = new Uint8Array(16);
  244. const view = new DataView(digest.buffer);
  245. view.setUint32(0, a0, true);
  246. view.setUint32(4, b0, true);
  247. view.setUint32(8, c0, true);
  248. view.setUint32(12, d0, true);
  249. return digest;
  250. }
  251. function wakeupMd5Hex(input) {
  252. return Array.from(wakeupMd5Bytes(input), byte => byte.toString(16).padStart(2, "0")).join("");
  253. }
  254. function wakeupMd5Upper(input) {
  255. return wakeupMd5Hex(input).toUpperCase();
  256. }
  257. // 以下表与位序来自 WakeUP v6.1.70 官方协议。该私有 DES 并非标准 DES,禁止替换。
  258. const WAKEUP_DES_IP = [57, 49, 41, 33, 25, 17, 9, 1, 59, 51, 43, 35, 27, 19, 11, 3, 61, 53, 45, 37, 29, 21, 13, 5, 63, 55, 47, 39, 31, 23, 15, 7, 56, 48, 40, 32, 24, 16, 8, 0, 58, 50, 42, 34, 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4, 62, 54, 46, 38, 30, 22, 14, 6];
  259. const WAKEUP_DES_FP = [39, 7, 47, 15, 55, 23, 63, 31, 38, 6, 46, 14, 54, 22, 62, 30, 37, 5, 45, 13, 53, 21, 61, 29, 36, 4, 44, 12, 52, 20, 60, 28, 35, 3, 43, 11, 51, 19, 59, 27, 34, 2, 42, 10, 50, 18, 58, 26, 33, 1, 41, 9, 49, 17, 57, 25, 32, 0, 40, 8, 48, 16, 56, 24];
  260. const WAKEUP_DES_E = [31, 0, 1, 2, 3, 4, 3, 4, 5, 6, 7, 8, 7, 8, 9, 10, 11, 12, 11, 12, 13, 14, 15, 16, 15, 16, 17, 18, 19, 20, 19, 20, 21, 22, 23, 24, 23, 24, 25, 26, 27, 28, 27, 28, 29, 30, 31, 0];
  261. const WAKEUP_DES_P = [15, 6, 19, 20, 28, 11, 27, 16, 0, 14, 22, 25, 4, 17, 30, 9, 1, 7, 23, 13, 31, 26, 2, 8, 18, 12, 29, 5, 21, 10, 3, 24];
  262. const WAKEUP_DES_PC1 = [56, 48, 40, 32, 24, 16, 8, 0, 57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18, 10, 2, 59, 51, 43, 35, 62, 54, 46, 38, 30, 22, 14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 60, 52, 44, 36, 28, 20, 12, 4, 27, 19, 11, 3];
  263. const WAKEUP_DES_PC2 = [13, 16, 10, 23, 0, 4, 2, 27, 14, 5, 20, 9, 22, 18, 11, 3, 25, 7, 15, 6, 26, 19, 12, 1, 40, 51, 30, 36, 46, 54, 29, 39, 50, 44, 32, 46, 43, 48, 38, 55, 33, 52, 45, 41, 49, 35, 28, 31];
  264. const WAKEUP_DES_SHIFTS = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1];
  265. const WAKEUP_DES_SBOX = [
  266. [[14,4,13,1,2,15,11,8,3,10,6,12,5,9,0,7],[0,15,7,4,14,2,13,1,10,6,12,11,9,5,3,8],[4,1,14,8,13,6,2,11,15,12,9,7,3,10,5,0],[15,12,8,2,4,9,1,7,5,11,3,14,10,0,6,13]],
  267. [[15,1,8,14,6,11,3,4,9,7,2,13,12,0,5,10],[3,13,4,7,15,2,8,14,12,0,1,10,6,9,11,5],[0,14,7,11,10,4,13,1,5,8,12,6,9,3,2,15],[13,8,10,1,3,15,4,2,11,6,7,12,0,5,14,9]],
  268. [[10,0,9,14,6,3,15,5,1,13,12,7,11,4,2,8],[13,7,0,9,3,4,6,10,2,8,5,14,12,11,15,1],[13,6,4,9,8,15,3,0,11,1,2,12,5,10,14,7],[1,10,13,0,6,9,8,7,4,15,14,3,11,5,2,12]],
  269. [[7,13,14,3,0,6,9,10,1,2,8,5,11,12,4,15],[13,8,11,5,6,15,0,3,4,7,2,12,1,10,14,9],[10,6,9,0,12,11,7,13,15,1,3,14,5,2,8,4],[3,15,0,6,10,1,13,8,9,4,5,11,12,7,2,14]],
  270. [[2,12,4,1,7,10,11,6,8,5,3,15,13,0,14,9],[14,11,2,12,4,7,13,1,5,0,15,10,3,9,8,6],[4,2,1,11,10,13,7,8,15,9,12,5,6,3,0,14],[11,8,12,7,1,14,2,13,6,15,0,9,10,4,5,3]],
  271. [[12,1,10,15,9,2,6,8,0,13,3,4,14,7,5,11],[10,15,4,2,7,12,9,5,6,1,13,14,0,11,3,8],[9,14,15,5,2,8,12,3,7,0,4,10,1,13,11,6],[4,3,2,12,9,5,15,10,11,14,1,7,6,0,8,13]],
  272. [[4,11,2,14,15,0,8,13,3,12,9,7,5,10,6,1],[13,0,11,7,4,9,1,10,14,3,5,12,2,15,8,6],[1,4,11,13,12,3,7,14,10,15,6,8,0,5,9,2],[6,11,13,8,1,4,10,7,9,5,0,15,14,2,3,12]],
  273. [[13,2,8,4,6,15,11,1,10,9,3,14,5,0,12,7],[1,15,13,8,10,3,7,4,12,5,6,11,0,14,9,2],[7,11,4,1,9,12,14,2,0,6,10,13,15,3,5,8],[2,1,14,7,4,10,8,13,15,12,9,0,3,5,6,11]]
  274. ];
  275. function wakeupDesBitsFromBytes(bytes) {
  276. const output = new Array(bytes.length * 8);
  277. for (let index = 0; index < bytes.length; index += 1) {
  278. for (let bit = 0; bit < 8; bit += 1) output[index * 8 + bit] = (bytes[index] >> bit) & 1;
  279. }
  280. return output;
  281. }
  282. function wakeupDesBytesFromBits(bits) {
  283. const output = new Uint8Array(bits.length / 8);
  284. for (let index = 0; index < output.length; index += 1) {
  285. for (let bit = 0; bit < 8; bit += 1) output[index] |= (bits[index * 8 + bit] & 1) << bit;
  286. }
  287. return output;
  288. }
  289. function wakeupDesPermute(bits, table) {
  290. return table.map(index => bits[index] | 0);
  291. }
  292. function wakeupDesSubkeys(key) {
  293. const keyBytes = wakeupToBytes(key);
  294. if (keyBytes.length !== 8) throw new Error("WakeUP 私有 DES 密钥长度必须为 8 字节。");
  295. const bits = wakeupDesPermute(wakeupDesBitsFromBytes(keyBytes), WAKEUP_DES_PC1);
  296. let left = bits.slice(0, 28);
  297. let right = bits.slice(28);
  298. return WAKEUP_DES_SHIFTS.map(shift => {
  299. left = left.slice(shift).concat(left.slice(0, shift));
  300. right = right.slice(shift).concat(right.slice(0, shift));
  301. return wakeupDesPermute(left.concat(right), WAKEUP_DES_PC2);
  302. });
  303. }
  304. function wakeupDesFunction(right, subkey) {
  305. const expanded = wakeupDesPermute(right, WAKEUP_DES_E);
  306. const mixed = expanded.map((bit, index) => bit ^ subkey[index]);
  307. const output = [];
  308. for (let box = 0; box < 8; box += 1) {
  309. const block = mixed.slice(box * 6, box * 6 + 6);
  310. const row = block[0] * 2 + block[5];
  311. const column = block[1] * 8 + block[2] * 4 + block[3] * 2 + block[4];
  312. const value = WAKEUP_DES_SBOX[box][row][column];
  313. output.push((value >> 3) & 1, (value >> 2) & 1, (value >> 1) & 1, value & 1);
  314. }
  315. return wakeupDesPermute(output, WAKEUP_DES_P);
  316. }
  317. function wakeupDesBlock(block, subkeys) {
  318. const bits = wakeupDesPermute(wakeupDesBitsFromBytes(block), WAKEUP_DES_IP);
  319. let left = bits.slice(0, 32);
  320. let right = bits.slice(32);
  321. for (const subkey of subkeys) {
  322. const mixed = wakeupDesFunction(right, subkey);
  323. const nextRight = left.map((bit, index) => bit ^ mixed[index]);
  324. left = right;
  325. right = nextRight;
  326. }
  327. return wakeupDesBytesFromBits(wakeupDesPermute(right.concat(left), WAKEUP_DES_FP));
  328. }
  329. function wakeupConcatBytes(chunks) {
  330. const output = new Uint8Array(chunks.reduce((length, chunk) => length + chunk.length, 0));
  331. let offset = 0;
  332. chunks.forEach(chunk => {
  333. output.set(chunk, offset);
  334. offset += chunk.length;
  335. });
  336. return output;
  337. }
  338. function wakeupDesEncrypt(plain, key) {
  339. const source = wakeupToBytes(plain);
  340. const paddedLength = (source.length & ~7) + 8;
  341. const padded = new Uint8Array(paddedLength);
  342. padded.set(source);
  343. padded[paddedLength - 1] = paddedLength - source.length;
  344. const subkeys = wakeupDesSubkeys(key);
  345. const blocks = [];
  346. for (let offset = 0; offset < paddedLength; offset += 8) blocks.push(wakeupDesBlock(padded.subarray(offset, offset + 8), subkeys));
  347. return wakeupConcatBytes(blocks);
  348. }
  349. function wakeupDesDecrypt(cipher, key) {
  350. const source = wakeupToBytes(cipher);
  351. if (source.length % 8 !== 0) throw new Error("WakeUP 私有 DES 密文长度无效。");
  352. const subkeys = wakeupDesSubkeys(key).reverse();
  353. const blocks = [];
  354. for (let offset = 0; offset < source.length; offset += 8) blocks.push(wakeupDesBlock(source.subarray(offset, offset + 8), subkeys));
  355. const plain = wakeupConcatBytes(blocks);
  356. const padLength = plain[plain.length - 1];
  357. if (padLength > plain.length) throw new Error("WakeUP 私有 DES 填充无效。");
  358. return plain.subarray(0, plain.length - padLength);
  359. }
  360. function wakeupReverseNibble(value) {
  361. return ((value & 1) << 3) | ((value & 2) << 1) | ((value & 4) >> 1) | ((value & 8) >> 3);
  362. }
  363. function wakeupNativeHexEncode(bytes) {
  364. return Array.from(wakeupToBytes(bytes), byte => {
  365. const low = wakeupReverseNibble(byte & 0x0f).toString(16).padStart(2, "0");
  366. const high = wakeupReverseNibble((byte >> 4) & 0x0f).toString(16).padStart(2, "0");
  367. return low + high;
  368. }).join("");
  369. }
  370. function wakeupNativeHexDecode(text) {
  371. const source = String(text).slice(0, Math.floor(String(text).length / 4) * 4);
  372. const output = new Uint8Array(source.length / 4);
  373. for (let index = 0; index < source.length; index += 4) {
  374. const low = wakeupReverseNibble(parseInt(source[index + 1], 16));
  375. const high = wakeupReverseNibble(parseInt(source[index + 3], 16));
  376. output[index / 4] = low | (high << 4);
  377. }
  378. return output;
  379. }
  380. function wakeupRc4(data, key) {
  381. const keyBytes = wakeupToBytes(key);
  382. const state = new Uint8Array(256);
  383. for (let index = 0; index < 256; index += 1) state[index] = index;
  384. let swapIndex = 0;
  385. for (let index = 0; index < 256; index += 1) {
  386. swapIndex = (swapIndex + state[index] + keyBytes[index % keyBytes.length]) & 0xff;
  387. [state[index], state[swapIndex]] = [state[swapIndex], state[index]];
  388. }
  389. const input = wakeupToBytes(data);
  390. const output = new Uint8Array(input.length);
  391. let left = 0;
  392. let right = 0;
  393. for (let index = 0; index < input.length; index += 1) {
  394. left = (left + 1) & 0xff;
  395. right = (right + state[left]) & 0xff;
  396. [state[left], state[right]] = [state[right], state[left]];
  397. output[index] = input[index] ^ state[(state[left] + state[right]) & 0xff];
  398. }
  399. return output;
  400. }
  401. /** WakeUP v6.1.70 的设备标识、签名、反爬握手和加密请求实现。 */
  402. function wakeupCuidFromAndroidId(androidId) {
  403. return `${wakeupMd5Upper(`com.baidu${androidId || ""}`)}|0`;
  404. }
  405. function wakeupAdidFromAndroidId(androidId) {
  406. const prefix = wakeupMd5Hex(`alpha.beta${androidId || ""}`);
  407. const folded = BigInt(`0x${prefix.slice(0, 16)}`) ^ BigInt(`0x${prefix.slice(16)}`);
  408. const checksum = (((folded >> 32n) ^ folded) & 0xffffffffn).toString(16).padStart(8, "0");
  409. return prefix + checksum;
  410. }
  411. function wakeupGenerateRand10() {
  412. const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
  413. const bytes = new Uint8Array(10);
  414. if (!globalThis.crypto || !globalThis.crypto.getRandomValues) throw new Error("当前 WebView 不支持安全随机数生成。");
  415. globalThis.crypto.getRandomValues(bytes);
  416. return Array.from(bytes, byte => alphabet[byte % alphabet.length]).join("");
  417. }
  418. function wakeupCreateDevice() {
  419. const device = { ...WAKEUP_DEVICE_DEFAULTS };
  420. device.cuid = wakeupCuidFromAndroidId(device.androidId);
  421. device.adid = device.adid || wakeupAdidFromAndroidId(device.androidId);
  422. return device;
  423. }
  424. function wakeupMakeCommonParams(device) {
  425. return [["area", device.area], ["screensize", device.screensize], ["cuid", device.cuid], ["os", "android"],
  426. ["city", device.city], ["abis", device.abis], ["channel", WAKEUP_V6170.channel], ["appBit", device.appBit],
  427. ["vc", String(WAKEUP_V6170.versionCode)], ["deviceId", device.deviceId], ["token", WAKEUP_V6170.publicToken],
  428. ["adid", device.adid], ["province", device.province], ["pkgName", WAKEUP_V6170.packageName], ["appId", device.appId],
  429. ["download_type", device.downloadType], ["vcname", WAKEUP_V6170.versionName], ["sdk", String(device.sdk)],
  430. ["device", device.device], ["brand", device.brand], ["operatorid", device.operatorid]]
  431. .map(([key, value]) => [key, value == null ? "" : String(value)]);
  432. }
  433. function wakeupMakeSignA(cuid) {
  434. const rand10 = wakeupGenerateRand10();
  435. const plain = `${WAKEUP_V6170.magic}##${rand10}##${WAKEUP_V6170.signatureMd5}##${cuid}`;
  436. return { signA: wakeupNativeHexEncode(wakeupDesEncrypt(plain, WAKEUP_V6170.signAKey)), rand10 };
  437. }
  438. function wakeupTokenFromSignB(signB, rand10) {
  439. const plain = wakeupDesDecrypt(wakeupNativeHexDecode(signB), `${rand10.slice(0, 5)}#G4`);
  440. const text = wakeupBytesToLatin1(plain);
  441. if (text.length < 22 || text.slice(0, 10) !== rand10) throw new Error("WakeUP antispam token 校验失败。");
  442. return text.slice(12, 22);
  443. }
  444. function wakeupGetRc4Key(token) {
  445. const first = wakeupMd5Hex(WAKEUP_V6170.keySalt);
  446. const second = wakeupMd5Hex(String(WAKEUP_V6170.versionCode));
  447. const raw = wakeupMd5Hex(`[${token}]@`);
  448. const reversed = raw.slice(17).split("").reverse().join("") + raw.slice(15, 17) + raw.slice(0, 15).split("").reverse().join("");
  449. const chars = (first + second + reversed).split("");
  450. for (let index = 0; index < 3; index += 1) {
  451. const right = chars.length - 1 - index;
  452. [chars[index], chars[right]] = [chars[right], chars[index]];
  453. }
  454. const output = (chars.join("") + wakeupMd5Hex(chars.join(""))).split("");
  455. for (let index = 0; index < 60; index += 1) {
  456. [output[index], output[output.length - 1 - index]] = [output[output.length - 1 - index], output[index]];
  457. }
  458. return output.join("");
  459. }
  460. function wakeupGetSign(base64Params, token) {
  461. return wakeupMd5Hex(`${WAKEUP_V6170.magic}[${wakeupMd5Hex(token)}]@${base64Params}`);
  462. }
  463. function wakeupBuildShareRequest(code, token, device, commonParams) {
  464. const rc4Key = wakeupGetRc4Key(token);
  465. const dataValue = wakeupBytesToBase64(wakeupRc4(`key=${wakeupAndroidQuote(code)}`, rc4Key));
  466. const serverTime = Math.floor(Date.now() / 1000);
  467. const kakorr = Date.now();
  468. const signItems = [`data=${dataValue}`, ...commonParams.map(([key, value]) => `${key}=${value}`)];
  469. if (device.did) signItems.push(`did=${device.did}`);
  470. signItems.push(`nt=${device.nt}`, `_t_=${serverTime}`, `kakorrhaphiophobia=${kakorr}`);
  471. const sign = wakeupGetSign(wakeupBytesToBase64(new TextEncoder().encode(signItems.sort().join(""))), token);
  472. const extras = device.did ? [["did", device.did]] : [];
  473. const body = `&${wakeupFormEncode([["data", dataValue], ...commonParams, ...extras, ["nt", device.nt]])}&sign=${sign}&_t_=${serverTime}&kakorrhaphiophobia=${kakorr}`;
  474. return { rc4Key, body };
  475. }
  476. function wakeupExtractSignB(payload) {
  477. if (!payload || typeof payload !== "object") return "";
  478. let candidate = payload.data;
  479. if (candidate && typeof candidate === "object") candidate = candidate.data;
  480. if (typeof candidate !== "string" || !candidate) candidate = payload.result && typeof payload.result === "object" ? payload.result.data : null;
  481. return typeof candidate === "string" ? candidate : "";
  482. }
  483. async function wakeupPostForm(url, body, device) {
  484. const headers = { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "na__zyb_source__": "wakeup", "zyb-cuid": device.cuid, "zyb-adid": device.adid };
  485. if (device.did) headers["zyb-did"] = device.did;
  486. const controller = typeof AbortController === "undefined" ? null : new AbortController();
  487. const timeout = controller ? setTimeout(() => controller.abort(), 15000) : null;
  488. try {
  489. const response = await fetch(url, { method: "POST", headers, body, signal: controller ? controller.signal : undefined });
  490. const text = await response.text();
  491. if (!response.ok) throw new Error(`WakeUP 服务请求失败(HTTP ${response.status})。`);
  492. return text;
  493. } finally {
  494. if (timeout) clearTimeout(timeout);
  495. }
  496. }
  497. /**
  498. * 执行 WakeUP 官方 v6.1.70 两段式请求,并返回解密后的课表分享数据。
  499. * 敏感口令、签名和 token 不写入日志,避免泄漏。
  500. */
  501. async function wakeupDecodeShareCode(code) {
  502. const device = wakeupCreateDevice();
  503. const commonParams = wakeupMakeCommonParams(device);
  504. const { signA, rand10 } = wakeupMakeSignA(device.cuid);
  505. const antispamBody = `${wakeupFormEncode([["data", signA], ...commonParams])}&`;
  506. const antispamText = await wakeupPostForm(`${WAKEUP_V6170.host}${WAKEUP_V6170.antispamPath}`, antispamBody, device);
  507. let antispamJson;
  508. try {
  509. antispamJson = JSON.parse(antispamText);
  510. } catch {
  511. throw new Error("WakeUP antispam 响应格式无效。");
  512. }
  513. const signB = wakeupExtractSignB(antispamJson);
  514. if (!signB) throw new Error("WakeUP antispam 响应未包含签名数据。");
  515. const token = wakeupTokenFromSignB(signB, rand10);
  516. const shareRequest = wakeupBuildShareRequest(code, token, device, commonParams);
  517. const shareText = await wakeupPostForm(`${WAKEUP_V6170.host}${WAKEUP_V6170.sharePath}`, shareRequest.body, device);
  518. let shareJson;
  519. try {
  520. shareJson = JSON.parse(shareText);
  521. } catch {
  522. throw new Error("WakeUP 课表响应格式无效。");
  523. }
  524. const encrypted = shareJson && typeof shareJson.data === "object" ? shareJson.data.data : shareJson && shareJson.data;
  525. if (typeof encrypted !== "string" || !encrypted) throw new Error("WakeUP 课表响应未包含加密数据。");
  526. let decrypted;
  527. try {
  528. decrypted = JSON.parse(wakeupBytesToUtf8(wakeupRc4(wakeupBase64ToBytes(encrypted), shareRequest.rc4Key)));
  529. } catch {
  530. throw new Error("WakeUP 课表响应解密失败。");
  531. }
  532. if (!decrypted || typeof decrypted.shareData !== "string") throw new Error("WakeUP 解密数据中未找到 shareData。");
  533. return decrypted.shareData;
  534. }
  535. /** 将 WakeUP 分享数据(多个换行分隔的 JSON 块)解析成各部分。 */
  536. function parseRawScheduleData(rawData) {
  537. window.shiguangBridge.showToast("正在解析原始数据...");
  538. const parts = rawData.trim().split("\n");
  539. if (parts.length < 5) throw new Error("数据格式不完整,预期至少包含 5 个部分。");
  540. return {
  541. baseConfig: JSON.parse(parts[0]),
  542. timeSlotsRaw: JSON.parse(parts[1]),
  543. uiConfig: JSON.parse(parts[2]),
  544. coursesRaw: JSON.parse(parts[3]),
  545. courseDetailRaw: JSON.parse(parts[4])
  546. };
  547. }
  548. function formatDateToYYYYMMDD(dateObj) {
  549. const year = dateObj.getFullYear();
  550. const month = String(dateObj.getMonth() + 1).padStart(2, "0");
  551. const day = String(dateObj.getDate()).padStart(2, "0");
  552. return `${year}-${month}-${day}`;
  553. }
  554. function convertToSemesterStartDate(rawDate) {
  555. if (!rawDate || String(rawDate).trim().length === 0) return null;
  556. const dateObj = new Date(String(rawDate).trim().replace(/\//g, "-"));
  557. if (isNaN(dateObj.getTime())) {
  558. console.warn(`WARN: 无法将原始日期值 "${rawDate}" 转换为有效日期。`);
  559. return null;
  560. }
  561. return formatDateToYYYYMMDD(dateObj);
  562. }
  563. /** 通过 WakeUP v6.1.70 官方渠道获取、解密、解析并转换课程表。 */
  564. async function fetchAndParseData(shareKey) {
  565. try {
  566. window.shiguangBridge.showToast("正在通过 WakeUP 官方渠道请求课表数据...");
  567. const parsedData = parseRawScheduleData(await wakeupDecodeShareCode(shareKey.trim()));
  568. let rawNodes = parsedData.uiConfig.nodes;
  569. if (!Array.isArray(rawNodes)) {
  570. if (typeof rawNodes === "number" && rawNodes > 0) {
  571. rawNodes = Array.from({ length: rawNodes }, (_, index) => index + 1);
  572. } else {
  573. console.warn(`WARN: uiConfig.nodes 数据无效 (${rawNodes}),已重置为空数组。`);
  574. rawNodes = [];
  575. }
  576. }
  577. const validNodes = new Set(rawNodes);
  578. const timeSlots = parsedData.timeSlotsRaw
  579. .filter(slot => slot.startTime !== "00:00" && slot.endTime !== "00:00")
  580. .filter(slot => validNodes.has(slot.node))
  581. .map(slot => ({ number: slot.node, startTime: slot.startTime, endTime: slot.endTime }));
  582. const courseConfig = {
  583. semesterStartDate: convertToSemesterStartDate(parsedData.uiConfig.startDate),
  584. semesterTotalWeeks: parsedData.uiConfig.maxWeek,
  585. defaultClassDuration: parsedData.baseConfig.courseLen,
  586. defaultBreakDuration: parsedData.baseConfig.theBreakLen
  587. };
  588. const courses = convertToCourseJsonModel(parsedData);
  589. window.shiguangBridge.showToast(`数据解析成功,共 ${courses.length} 门课程`);
  590. return { timeSlots, courseConfig, courses };
  591. } catch (error) {
  592. console.error("WakeUP 数据获取或解析失败:", error);
  593. window.shiguangBridge.showToast(`数据获取或解析失败: ${error.message}`);
  594. return null;
  595. }
  596. }
  597. /**
  598. * 将课程数据从原始结构转换为 CourseJsonModel 格式。
  599. * @param {object} parsedData 包含 coursesRaw 和 courseDetailRaw 的解析数据。
  600. * @returns {Array<object>} 符合 CourseJsonModel 结构的课程数组。
  601. */
  602. function convertToCourseJsonModel(parsedData) {
  603. const { coursesRaw, courseDetailRaw } = parsedData;
  604. const finalCourses = [];
  605. // 创建课程ID到课程信息的映射
  606. const courseMap = coursesRaw.reduce((map, course) => {
  607. map[course.id] = course;
  608. return map;
  609. }, {});
  610. // 遍历课程安排详情,构建最终的 CourseJsonModel
  611. courseDetailRaw.forEach(detail => {
  612. if (detail.id === undefined || detail.id === null) return;
  613. const courseInfo = courseMap[detail.id];
  614. if (!courseInfo) return;
  615. // 计算 weeks 数组
  616. const weeks = [];
  617. for (let i = detail.startWeek; i <= detail.endWeek; i++) {
  618. if (detail.type === 0 || // 每周
  619. (detail.type === 1 && i % 2 !== 0) || // 单周 (奇数周)
  620. (detail.type === 2 && i % 2 === 0)) { // 双周 (偶数周)
  621. weeks.push(i);
  622. }
  623. }
  624. // 构造 CourseJsonModel 对象
  625. const course = {
  626. "name": courseInfo.courseName,
  627. "teacher": detail.teacher || "",
  628. "position": detail.room || "",
  629. "day": detail.day,
  630. "weeks": weeks
  631. };
  632. // 处理自定义时间 (ownTime)
  633. if (detail.ownTime === true && detail.startTime && detail.endTime) {
  634. // 使用自定义时间
  635. course.isCustomTime = true;
  636. course.customStartTime = detail.startTime;
  637. course.customEndTime = detail.endTime;
  638. } else {
  639. // 使用标准节次
  640. course.startSection = detail.startNode;
  641. course.endSection = detail.startNode + detail.step - 1;
  642. }
  643. finalCourses.push(course);
  644. });
  645. // 应用合并去重
  646. return mergeAndDistinctCourses(finalCourses);
  647. }
  648. async function saveTimeSlots(timeSlots) {
  649. if (timeSlots.length === 0) {
  650. window.shiguangBridge.showToast("没有可导入的时间段数据。");
  651. return true;
  652. }
  653. try {
  654. console.log("正在导入时间段...");
  655. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
  656. window.shiguangBridge.showToast(`成功导入 ${timeSlots.length} 个时间段!`);
  657. return true;
  658. } catch (error) {
  659. console.error("导入时间段失败:", error);
  660. window.shiguangBridge.showToast("导入时间段失败: " + error.message);
  661. return false;
  662. }
  663. }
  664. async function saveConfig(configData) {
  665. try {
  666. console.log("正在导入课表配置...");
  667. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(configData));
  668. window.shiguangBridge.showToast("课表配置(学期/时长)更新成功!");
  669. return true;
  670. } catch (error) {
  671. console.error("导入配置失败:", error);
  672. window.shiguangBridge.showToast("导入配置失败: " + error.message);
  673. return false;
  674. }
  675. }
  676. async function saveCourses(courses) {
  677. if (courses.length === 0) {
  678. window.shiguangBridge.showToast("没有课程数据需要导入。");
  679. return true;
  680. }
  681. try {
  682. console.log("正在导入课程数据...");
  683. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
  684. window.shiguangBridge.showToast(`成功导入 ${courses.length} 门课程!`);
  685. return true;
  686. } catch (error) {
  687. console.error("导入课程失败:", error);
  688. window.shiguangBridge.showToast("导入课程失败: " + error.message);
  689. return false;
  690. }
  691. }
  692. async function runImportFlow() {
  693. console.log("Wakeup 课表分享导入流程启动...");
  694. window.shiguangBridge.showToast("课表导入流程即将开始...");
  695. // 获取用户输入
  696. const userInput = await window.shiguangBridgePromise.showPrompt(
  697. "输入课表分享口令",
  698. "可直接粘贴分享的整段文本,系统会自动提取 Key",
  699. "",
  700. "validateKey"
  701. );
  702. if (userInput === null) {
  703. window.shiguangBridge.showToast("导入已取消。");
  704. return;
  705. }
  706. // 提取口令(从「」内或文本特征中提取 32 位 Key)
  707. const shareKey = extractKeyFromText(userInput);
  708. // 网络请求和数据解析
  709. const parsed = await fetchAndParseData(shareKey);
  710. if (parsed === null) {
  711. return;
  712. }
  713. // 导入时间段
  714. const timeSlotResult = await saveTimeSlots(parsed.timeSlots);
  715. if (!timeSlotResult) {
  716. return;
  717. }
  718. // 导入配置
  719. const configResult = await saveConfig(parsed.courseConfig);
  720. if (!configResult) {
  721. return;
  722. }
  723. // 导入课程数据
  724. const courseSaveResult = await saveCourses(parsed.courses);
  725. if (!courseSaveResult) {
  726. return;
  727. }
  728. // 流程完全成功,发送结束信号
  729. window.shiguangBridge.showToast("所有任务已成功完成!");
  730. window.shiguangBridge.notifyTaskCompletion();
  731. }
  732. // 启动导入流程
  733. runImportFlow();