backend APis complete
This commit is contained in:
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
skyward_screenshots/**
|
||||
|
||||
skywardstate.json
|
||||
15
README.md
Normal file
15
README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# skyward-analysis
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.2.15. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
|
||||
242
bun.lock
Normal file
242
bun.lock
Normal file
@@ -0,0 +1,242 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "skyward-analysis",
|
||||
"dependencies": {
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
"pino": "^10.1.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"playwright": "^1.57.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/express": "^5.0.6",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||
|
||||
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="],
|
||||
|
||||
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
|
||||
|
||||
"@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="],
|
||||
|
||||
"@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.0", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA=="],
|
||||
|
||||
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
|
||||
|
||||
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
|
||||
|
||||
"@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="],
|
||||
|
||||
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
|
||||
|
||||
"@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
|
||||
|
||||
"@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.1", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"fast-copy": ["fast-copy@4.0.2", "", {}, "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw=="],
|
||||
|
||||
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
|
||||
|
||||
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.1", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
|
||||
|
||||
"pino": ["pino@10.1.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w=="],
|
||||
|
||||
"pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
|
||||
|
||||
"pino-pretty": ["pino-pretty@13.1.3", "", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^4.0.0", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pump": "^3.0.0", "secure-json-parse": "^4.0.0", "sonic-boom": "^4.0.1", "strip-json-comments": "^5.0.2" }, "bin": { "pino-pretty": "bin.js" } }, "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg=="],
|
||||
|
||||
"pino-std-serializers": ["pino-std-serializers@7.0.0", "", {}, "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA=="],
|
||||
|
||||
"playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="],
|
||||
|
||||
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
|
||||
|
||||
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
|
||||
|
||||
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="],
|
||||
|
||||
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"sonic-boom": ["sonic-boom@4.2.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww=="],
|
||||
|
||||
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
|
||||
|
||||
"thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"pino-pretty/pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
|
||||
}
|
||||
}
|
||||
998
index.ts
Normal file
998
index.ts
Normal file
@@ -0,0 +1,998 @@
|
||||
import { firefox } from 'playwright';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
||||
import pino from 'pino';
|
||||
import { Page } from 'playwright';
|
||||
import express from 'express';
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const logger = pino({
|
||||
transport: {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const SCREENSHOTS_DIR = 'skyward_screenshots';
|
||||
const TARGET_CATEGORIES = ['NW1', 'NW2', 'SE1', 'NW3', 'NW4', 'SE2'];
|
||||
|
||||
// API endpoint to fetch grades
|
||||
app.post('/fetch-grades', async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({
|
||||
error: 'Username and password are required',
|
||||
message: 'Please provide both username and password in request body'
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`Received request to fetch grades for user: ${username}`);
|
||||
|
||||
try {
|
||||
const grades = await runSkywardBot(username, password);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
totalClasses: grades.length,
|
||||
grades: grades
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Error fetching grades: ${errorMsg}`);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
message: 'Failed to fetch grades from Skyward'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', message: 'Skyward API is running' });
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`🚀 Skyward API server running on port ${PORT}`);
|
||||
logger.info(`📡 POST /fetch-grades - Fetch grades (requires username and password)`);
|
||||
logger.info(`💚 GET /health - Health check`);
|
||||
});
|
||||
|
||||
app.post('/check-auth', async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Username and password required',
|
||||
});
|
||||
}
|
||||
|
||||
const browser = await firefox.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
const authenticated = await checkAuth(page, username, password, context, []);
|
||||
|
||||
if (!authenticated) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Authentication failed',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true });
|
||||
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: msg,
|
||||
});
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
|
||||
const runSkywardBot = async (username: string, password: string) => {
|
||||
const allClassGrades: {
|
||||
className: string;
|
||||
teacher: string;
|
||||
period: string;
|
||||
category: string;
|
||||
grades: Array<{
|
||||
name: string;
|
||||
dueDate: string;
|
||||
score: string;
|
||||
attempts: string;
|
||||
isMajorGrade: boolean;
|
||||
}>;
|
||||
}[] = [];
|
||||
|
||||
logger.info('Starting Skyward bot with Firefox on Linux...');
|
||||
|
||||
// Create screenshots directory if it doesn't exist
|
||||
if (!existsSync(SCREENSHOTS_DIR)) {
|
||||
mkdirSync(SCREENSHOTS_DIR);
|
||||
}
|
||||
|
||||
const browser = await firefox.launch({
|
||||
headless: true,
|
||||
firefoxUserPrefs: {
|
||||
'dom.webdriver.enabled': false,
|
||||
'media.peerconnection.enabled': false,
|
||||
'useAutomationExtension': false,
|
||||
},
|
||||
});
|
||||
|
||||
let page: Page | null = null;
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
userAgent:
|
||||
'Mozilla/5.0 (X11; Linux x86_64; rv:117.0) Gecko/20100101 Firefox/117.0',
|
||||
viewport: { width: 1280, height: 720 },
|
||||
});
|
||||
|
||||
page = await context.newPage();
|
||||
|
||||
await loginToSkyward(page, username, password, context, allClassGrades);
|
||||
|
||||
logger.info('Waiting 3 seconds before finishing...');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
return allClassGrades;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Fatal error in main: ${errorMsg}`);
|
||||
if (page) {
|
||||
// try {
|
||||
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/FATAL_ERROR.png` });
|
||||
// } catch (screenshotError) {
|
||||
// logger.warn('Could not take error screenshot');
|
||||
// }
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close();
|
||||
logger.info('Browser closed, done.');
|
||||
}
|
||||
};
|
||||
|
||||
async function checkAuth(
|
||||
page: Page,
|
||||
username: string,
|
||||
password: string,
|
||||
context: any,
|
||||
allClassGrades: any[]
|
||||
): Promise<boolean> {
|
||||
logger.info('Starting login process...');
|
||||
|
||||
// Step 1: Navigate to ClassLink launchpad
|
||||
logger.info('Step 1: Navigating to ClassLink launchpad...');
|
||||
await page.goto('https://launchpad.classlink.com/pisd', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30000,
|
||||
});
|
||||
await page.waitForTimeout(2000);
|
||||
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/01-launchpad-page.png` });
|
||||
|
||||
// Step 2: Click "PISD Network Login" button
|
||||
logger.info('Step 2: Looking for PISD Network Login button...');
|
||||
const pisdLoginButtonSelectors = [
|
||||
'button.saml.UseTMS:has-text("PISD Network Login")',
|
||||
'button[title="Sign in with SAML"]:has-text("PISD Network Login")',
|
||||
'button.btn-primary:has-text("PISD Network Login")',
|
||||
'button:has-text("PISD Network Login")',
|
||||
];
|
||||
|
||||
let pisdButton = null;
|
||||
for (const selector of pisdLoginButtonSelectors) {
|
||||
try {
|
||||
pisdButton = await page.$(selector);
|
||||
if (pisdButton) {
|
||||
const isVisible = await pisdButton.isVisible();
|
||||
if (isVisible) {
|
||||
logger.info(`Found PISD Network Login button with selector: ${selector}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!pisdButton) {
|
||||
logger.error('Could not find PISD Network Login button');
|
||||
throw new Error('PISD Network Login button not found');
|
||||
}
|
||||
|
||||
await pisdButton.click();
|
||||
logger.info('Clicked PISD Network Login button');
|
||||
await page.waitForTimeout(2000);
|
||||
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/02-after-pisd-button-click.png` });
|
||||
|
||||
// Step 3: Enter username
|
||||
logger.info('Step 3: Entering username...');
|
||||
const usernameSelector = 'input#userNameInput';
|
||||
await page.waitForSelector(usernameSelector, { state: 'visible', timeout: 15000 });
|
||||
await page.click(usernameSelector);
|
||||
await page.waitForTimeout(500);
|
||||
await page.type(usernameSelector, username, { delay: 100 });
|
||||
logger.info('Username entered');
|
||||
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/03-username-entered.png` });
|
||||
|
||||
// Step 4: Enter password
|
||||
logger.info('Step 4: Entering password...');
|
||||
const passwordSelector = 'input#passwordInput';
|
||||
await page.waitForSelector(passwordSelector, { state: 'visible', timeout: 15000 });
|
||||
await page.click(passwordSelector);
|
||||
await page.waitForTimeout(500);
|
||||
await page.type(passwordSelector, password, { delay: 100 });
|
||||
logger.info('Password entered');
|
||||
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/04-password-entered.png` });
|
||||
|
||||
// Step 5: Click submit button
|
||||
logger.info('Step 5: Clicking submit button...');
|
||||
|
||||
// First wait a bit for the page to be ready
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const submitSelectors = [
|
||||
'input#submitButton',
|
||||
'button#submitButton',
|
||||
'input[type="submit"]',
|
||||
'button[type="submit"]',
|
||||
'input[value="Sign in"]',
|
||||
'button:has-text("Sign in")',
|
||||
'text="Sign in"',
|
||||
'[type="submit"]',
|
||||
'.submit-button',
|
||||
'button.btn',
|
||||
'input.btn',
|
||||
];
|
||||
|
||||
let submitButton = null;
|
||||
for (const selector of submitSelectors) {
|
||||
try {
|
||||
logger.info(`Trying selector: ${selector}`);
|
||||
submitButton = await page.$(selector);
|
||||
if (submitButton) {
|
||||
const isVisible = await submitButton.isVisible();
|
||||
const isEnabled = await submitButton.isEnabled();
|
||||
logger.info(`Found element with ${selector} - visible: ${isVisible}, enabled: ${isEnabled}`);
|
||||
if (isVisible && isEnabled) {
|
||||
logger.info(`Found submit button with selector: ${selector}`);
|
||||
break;
|
||||
} else {
|
||||
submitButton = null;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Selector ${selector} failed: ${err}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// If still not found, try getting all buttons and find one with "Sign in" text
|
||||
if (!submitButton) {
|
||||
logger.info('Trying to find button by text content...');
|
||||
const allButtons = await page.$$('button, input[type="submit"]');
|
||||
for (const btn of allButtons) {
|
||||
try {
|
||||
const text = await btn.textContent();
|
||||
const value = await btn.getAttribute('value');
|
||||
const ariaLabel = await btn.getAttribute('aria-label');
|
||||
|
||||
logger.info(`Button found - text: "${text}", value: "${value}", aria-label: "${ariaLabel}"`);
|
||||
|
||||
if (
|
||||
(text && text.toLowerCase().includes('sign in')) ||
|
||||
(value && value.toLowerCase().includes('sign in')) ||
|
||||
(ariaLabel && ariaLabel.toLowerCase().includes('sign in'))
|
||||
) {
|
||||
const isVisible = await btn.isVisible();
|
||||
const isEnabled = await btn.isEnabled();
|
||||
if (isVisible && isEnabled) {
|
||||
submitButton = btn;
|
||||
logger.info(`Found submit button by text content`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!submitButton) {
|
||||
logger.error('Could not find submit button');
|
||||
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/05-submit-not-found.png`, fullPage: true });
|
||||
throw new Error('Submit button not found');
|
||||
}
|
||||
|
||||
await submitButton.click();
|
||||
logger.info('Submit button clicked');
|
||||
await page.waitForTimeout(3000);
|
||||
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/05-after-submit.png` });
|
||||
|
||||
// Wait for redirect back to launchpad
|
||||
logger.info('Waiting for redirect to launchpad...');
|
||||
await page.waitForTimeout(3000); // Wait a bit for redirect to happen
|
||||
|
||||
const currentUrl = page.url();
|
||||
logger.info(`Current URL after redirect: ${currentUrl}`);
|
||||
|
||||
try {
|
||||
await page.waitForURL('**/myapps.classlink.com/**', { timeout: 10000 });
|
||||
logger.info('Successfully matched myapps.classlink.com URL pattern');
|
||||
return true;
|
||||
|
||||
} catch (err) {
|
||||
logger.warn(`URL pattern didn't match, but we're at: ${page.url()}`);
|
||||
logger.warn('Continuing anyway...');
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
async function loginToSkyward(page: Page, username: string, password: string, context: any, allClassGrades: any[]) {
|
||||
logger.info('Starting login process...');
|
||||
|
||||
// Step 1: Navigate to ClassLink launchpad
|
||||
logger.info('Step 1: Navigating to ClassLink launchpad...');
|
||||
await page.goto('https://launchpad.classlink.com/pisd', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30000,
|
||||
});
|
||||
await page.waitForTimeout(2000);
|
||||
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/01-launchpad-page.png` });
|
||||
|
||||
// Step 2: Click "PISD Network Login" button
|
||||
logger.info('Step 2: Looking for PISD Network Login button...');
|
||||
const pisdLoginButtonSelectors = [
|
||||
'button.saml.UseTMS:has-text("PISD Network Login")',
|
||||
'button[title="Sign in with SAML"]:has-text("PISD Network Login")',
|
||||
'button.btn-primary:has-text("PISD Network Login")',
|
||||
'button:has-text("PISD Network Login")',
|
||||
];
|
||||
|
||||
let pisdButton = null;
|
||||
for (const selector of pisdLoginButtonSelectors) {
|
||||
try {
|
||||
pisdButton = await page.$(selector);
|
||||
if (pisdButton) {
|
||||
const isVisible = await pisdButton.isVisible();
|
||||
if (isVisible) {
|
||||
logger.info(`Found PISD Network Login button with selector: ${selector}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!pisdButton) {
|
||||
logger.error('Could not find PISD Network Login button');
|
||||
throw new Error('PISD Network Login button not found');
|
||||
}
|
||||
|
||||
await pisdButton.click();
|
||||
logger.info('Clicked PISD Network Login button');
|
||||
await page.waitForTimeout(2000);
|
||||
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/02-after-pisd-button-click.png` });
|
||||
|
||||
// Step 3: Enter username
|
||||
logger.info('Step 3: Entering username...');
|
||||
const usernameSelector = 'input#userNameInput';
|
||||
await page.waitForSelector(usernameSelector, { state: 'visible', timeout: 15000 });
|
||||
await page.click(usernameSelector);
|
||||
await page.waitForTimeout(500);
|
||||
await page.type(usernameSelector, username, { delay: 100 });
|
||||
logger.info('Username entered');
|
||||
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/03-username-entered.png` });
|
||||
|
||||
// Step 4: Enter password
|
||||
logger.info('Step 4: Entering password...');
|
||||
const passwordSelector = 'input#passwordInput';
|
||||
await page.waitForSelector(passwordSelector, { state: 'visible', timeout: 15000 });
|
||||
await page.click(passwordSelector);
|
||||
await page.waitForTimeout(500);
|
||||
await page.type(passwordSelector, password, { delay: 100 });
|
||||
logger.info('Password entered');
|
||||
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/04-password-entered.png` });
|
||||
|
||||
// Step 5: Click submit button
|
||||
logger.info('Step 5: Clicking submit button...');
|
||||
|
||||
// First wait a bit for the page to be ready
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const submitSelectors = [
|
||||
'input#submitButton',
|
||||
'button#submitButton',
|
||||
'input[type="submit"]',
|
||||
'button[type="submit"]',
|
||||
'input[value="Sign in"]',
|
||||
'button:has-text("Sign in")',
|
||||
'text="Sign in"',
|
||||
'[type="submit"]',
|
||||
'.submit-button',
|
||||
'button.btn',
|
||||
'input.btn',
|
||||
];
|
||||
|
||||
let submitButton = null;
|
||||
for (const selector of submitSelectors) {
|
||||
try {
|
||||
logger.info(`Trying selector: ${selector}`);
|
||||
submitButton = await page.$(selector);
|
||||
if (submitButton) {
|
||||
const isVisible = await submitButton.isVisible();
|
||||
const isEnabled = await submitButton.isEnabled();
|
||||
logger.info(`Found element with ${selector} - visible: ${isVisible}, enabled: ${isEnabled}`);
|
||||
if (isVisible && isEnabled) {
|
||||
logger.info(`Found submit button with selector: ${selector}`);
|
||||
break;
|
||||
} else {
|
||||
submitButton = null;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Selector ${selector} failed: ${err}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// If still not found, try getting all buttons and find one with "Sign in" text
|
||||
if (!submitButton) {
|
||||
logger.info('Trying to find button by text content...');
|
||||
const allButtons = await page.$$('button, input[type="submit"]');
|
||||
for (const btn of allButtons) {
|
||||
try {
|
||||
const text = await btn.textContent();
|
||||
const value = await btn.getAttribute('value');
|
||||
const ariaLabel = await btn.getAttribute('aria-label');
|
||||
|
||||
logger.info(`Button found - text: "${text}", value: "${value}", aria-label: "${ariaLabel}"`);
|
||||
|
||||
if (
|
||||
(text && text.toLowerCase().includes('sign in')) ||
|
||||
(value && value.toLowerCase().includes('sign in')) ||
|
||||
(ariaLabel && ariaLabel.toLowerCase().includes('sign in'))
|
||||
) {
|
||||
const isVisible = await btn.isVisible();
|
||||
const isEnabled = await btn.isEnabled();
|
||||
if (isVisible && isEnabled) {
|
||||
submitButton = btn;
|
||||
logger.info(`Found submit button by text content`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!submitButton) {
|
||||
logger.error('Could not find submit button');
|
||||
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/05-submit-not-found.png`, fullPage: true });
|
||||
throw new Error('Submit button not found');
|
||||
}
|
||||
|
||||
await submitButton.click();
|
||||
logger.info('Submit button clicked');
|
||||
await page.waitForTimeout(3000);
|
||||
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/05-after-submit.png` });
|
||||
|
||||
// Wait for redirect back to launchpad
|
||||
logger.info('Waiting for redirect to launchpad...');
|
||||
await page.waitForTimeout(3000); // Wait a bit for redirect to happen
|
||||
|
||||
const currentUrl = page.url();
|
||||
logger.info(`Current URL after redirect: ${currentUrl}`);
|
||||
|
||||
try {
|
||||
await page.waitForURL('**/myapps.classlink.com/**', { timeout: 10000 });
|
||||
logger.info('Successfully matched myapps.classlink.com URL pattern');
|
||||
} catch (err) {
|
||||
logger.warn(`URL pattern didn't match, but we're at: ${page.url()}`);
|
||||
logger.warn('Continuing anyway...');
|
||||
}
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/06-back-at-launchpad.png` });
|
||||
|
||||
await openSkywardFromLaunchpad(page, context, allClassGrades);
|
||||
}
|
||||
|
||||
async function openSkywardFromLaunchpad(page: Page, context: any, allClassGrades: any[]) {
|
||||
try {
|
||||
// Step 6: Search for Skyward
|
||||
logger.info('Step 6: Searching for Skyward app...');
|
||||
|
||||
// Look for search input
|
||||
const searchSelectors = [
|
||||
'input[aria-label="Search All Apps"]',
|
||||
'input#searchbar-filter-input',
|
||||
'input[data-cy="Input-Input"]',
|
||||
'input[placeholder*="Search"]',
|
||||
'input[type="search"]',
|
||||
'input#appsSearch',
|
||||
'input.search-input',
|
||||
];
|
||||
|
||||
let searchInput = null;
|
||||
for (const selector of searchSelectors) {
|
||||
try {
|
||||
logger.info(`Trying search selector: ${selector}`);
|
||||
searchInput = await page.$(selector);
|
||||
if (searchInput) {
|
||||
const isVisible = await searchInput.isVisible();
|
||||
if (isVisible) {
|
||||
logger.info(`Found search input with selector: ${selector}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!searchInput) {
|
||||
logger.error('Could not find search input');
|
||||
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/07-search-not-found.png`, fullPage: true });
|
||||
throw new Error('Search input not found');
|
||||
}
|
||||
|
||||
await searchInput.click();
|
||||
await page.waitForTimeout(500);
|
||||
await searchInput.type('Skyward', { delay: 100 });
|
||||
logger.info('Typed "Skyward" into search');
|
||||
await page.waitForTimeout(1000);
|
||||
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/07-skyward-search.png` });
|
||||
|
||||
// Step 7: Press Enter to select Skyward (opens in new tab)
|
||||
logger.info('Step 7: Pressing Enter to select Skyward...');
|
||||
|
||||
// Set up listener for new tab BEFORE pressing Enter
|
||||
const newPagePromise = new Promise<Page>((resolve) => {
|
||||
context.once('page', async (newPage) => {
|
||||
logger.info(`New tab opened with URL: ${newPage.url()}`);
|
||||
resolve(newPage);
|
||||
});
|
||||
});
|
||||
|
||||
await searchInput.press('Enter');
|
||||
logger.info('✓ Pressed Enter');
|
||||
|
||||
// Wait for new tab to open
|
||||
logger.info('Waiting for new tab to open...');
|
||||
const newPage = await Promise.race([
|
||||
newPagePromise,
|
||||
page.waitForTimeout(10000).then(() => null),
|
||||
]);
|
||||
|
||||
if (!newPage) {
|
||||
logger.error('No new tab opened');
|
||||
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/08-no-new-tab.png` });
|
||||
throw new Error('New tab did not open');
|
||||
}
|
||||
|
||||
logger.info(`New tab URL: ${newPage.url()}`);
|
||||
|
||||
// Wait for the new page to load
|
||||
await newPage.waitForLoadState('domcontentloaded');
|
||||
await newPage.waitForTimeout(3000);
|
||||
|
||||
const finalUrl = newPage.url();
|
||||
logger.info(`Final URL in new tab: ${finalUrl}`);
|
||||
await newPage.screenshot({ path: `${SCREENSHOTS_DIR}/08-new-tab-skyward.png` });
|
||||
|
||||
// Check if we're on Skyward
|
||||
if (finalUrl.includes('skyward')) {
|
||||
logger.info('✓ Successfully opened Skyward in new tab');
|
||||
} else {
|
||||
logger.warn(`New tab URL doesn't contain 'skyward': ${finalUrl}`);
|
||||
logger.info('Continuing anyway...');
|
||||
}
|
||||
|
||||
// Now navigate to grading on the NEW page
|
||||
await navigateToGrading(newPage, allClassGrades);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Error opening Skyward from launchpad: ${errorMsg}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function navigateToGrading(page: Page, allClassGrades: any[]) {
|
||||
logger.info('Step 8: Navigating to Grading section...');
|
||||
|
||||
// Wait for page to fully load
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Look for element with exact text "Grading"
|
||||
const gradingSelectors = [
|
||||
'text="Grading"',
|
||||
'a:has-text("Grading")',
|
||||
'button:has-text("Grading")',
|
||||
'div:has-text("Grading")',
|
||||
'[role="link"]:has-text("Grading")',
|
||||
'span:has-text("Grading")',
|
||||
];
|
||||
|
||||
let gradingElement = null;
|
||||
for (const selector of gradingSelectors) {
|
||||
try {
|
||||
const elements = await page.$$(selector);
|
||||
|
||||
// Check each element to find one with EXACT text "Grading"
|
||||
for (const element of elements) {
|
||||
const text = (await element.textContent())?.trim();
|
||||
if (text === 'Grading') {
|
||||
const isVisible = await element.isVisible();
|
||||
if (isVisible) {
|
||||
logger.info(`Found Grading element with selector: ${selector}`);
|
||||
gradingElement = element;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (gradingElement) break;
|
||||
} catch (err) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!gradingElement) {
|
||||
logger.error('Could not find Grading element with exact text');
|
||||
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/10-grading-not-found.png`, fullPage: true });
|
||||
throw new Error('Grading element not found');
|
||||
}
|
||||
|
||||
// Scroll element into view
|
||||
await gradingElement.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await gradingElement.click();
|
||||
logger.info('Clicked Grading element');
|
||||
await page.waitForTimeout(3000);
|
||||
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/10-grading-page.png`, fullPage: true });
|
||||
|
||||
logger.info('Successfully navigated to Grading page');
|
||||
|
||||
// Now find the grades table
|
||||
logger.info('Step 9: Finding grades table...');
|
||||
|
||||
// Wait for the table to load
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Find the table body
|
||||
const tableBody = await page.$('.unlockedBrowseBody.dynamicHeight');
|
||||
|
||||
if (!tableBody) {
|
||||
logger.error('Could not find grades table with class "unlockedBrowseBody dynamicHeight"');
|
||||
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/11-table-not-found.png`, fullPage: true });
|
||||
throw new Error('Grades table not found');
|
||||
}
|
||||
|
||||
logger.info('✓ Found grades table');
|
||||
|
||||
// Find all clickable grade cells (links with numbers)
|
||||
const gradeCells = await tableBody.$$('a.linkPanelButton');
|
||||
|
||||
logger.info(`Found ${gradeCells.length} clickable grade cells`);
|
||||
|
||||
if (gradeCells.length === 0) {
|
||||
logger.warn('No clickable grade cells found');
|
||||
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/11-no-grade-cells.png`, fullPage: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Click each grade cell
|
||||
for (let i = 0; i < gradeCells.length; i++) {
|
||||
try {
|
||||
logger.info(`\n=== Processing grade cell ${i + 1}/${gradeCells.length} ===`);
|
||||
|
||||
// Re-query the cell each time (in case DOM has changed)
|
||||
const allCells = await tableBody.$$('a.linkPanelButton');
|
||||
const cell = allCells[i];
|
||||
|
||||
if (!cell) {
|
||||
logger.warn(` Cell ${i + 1} not found, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get cell details
|
||||
const gradeText = (await cell.textContent())?.trim();
|
||||
const isVisible = await cell.isVisible();
|
||||
const dataRow = await cell.getAttribute('data-row');
|
||||
const dataColumn = await cell.getAttribute('data-column');
|
||||
const primaryKey = await cell.getAttribute('data-primarykey');
|
||||
|
||||
logger.info(` Grade: "${gradeText}"`);
|
||||
logger.info(` Row: ${dataRow}, Column: ${dataColumn}`);
|
||||
logger.info(` Primary Key: ${primaryKey}`);
|
||||
logger.info(` Visible: ${isVisible}`);
|
||||
|
||||
if (!isVisible) {
|
||||
logger.warn(` Cell ${i + 1} not visible, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scroll into view
|
||||
await cell.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Click the cell
|
||||
await cell.click();
|
||||
logger.info(` ✓ Clicked grade cell`);
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check for the category label - look inside SegmentedAssignmentsInformationCell span (not the one with "left")
|
||||
const categoryContainer = await page.$('span.SegmentedAssignmentsInformationCell:not(.left) label.labelTag.visualLabel');
|
||||
|
||||
if (!categoryContainer) {
|
||||
logger.info(` ℹ No category label found in SegmentedAssignmentsInformationCell, skipping`);
|
||||
} else {
|
||||
const categoryText = (await categoryContainer.textContent())?.trim();
|
||||
logger.info(` Category label found: "${categoryText}"`);
|
||||
|
||||
// Check if this is one of our target categories
|
||||
if (categoryText && TARGET_CATEGORIES.includes(categoryText)) {
|
||||
logger.info(` ✓ Target category "${categoryText}" detected - reading grades`);
|
||||
|
||||
// Get the class information from the left span
|
||||
const classInfoSpan = await page.$('span.SegmentedAssignmentsInformationCell.left');
|
||||
let className = '';
|
||||
let teacher = '';
|
||||
let period = '';
|
||||
|
||||
if (classInfoSpan) {
|
||||
const classLabel = await classInfoSpan.$('label.labelTag.visualLabel');
|
||||
if (classLabel) {
|
||||
className = (await classLabel.textContent())?.trim() || '';
|
||||
logger.info(` Class: "${className}"`);
|
||||
}
|
||||
|
||||
const teacherLink = await classInfoSpan.$('a span.anchorText');
|
||||
if (teacherLink) {
|
||||
teacher = (await teacherLink.textContent())?.trim() || '';
|
||||
logger.info(` Teacher: "${teacher}"`);
|
||||
}
|
||||
|
||||
const labels = await classInfoSpan.$$('label.labelTag.visualLabel');
|
||||
if (labels.length >= 2 && labels[1]) {
|
||||
period = (await labels[1].textContent())?.trim() || '';
|
||||
logger.info(` Period: "${period}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Find the details panel with grades
|
||||
const detailsPanel = await page.$('#detailsPanel_SegmentedGradeBucketAssignments_unlockedBody');
|
||||
|
||||
if (!detailsPanel) {
|
||||
logger.error(` ✗ Could not find detailsPanel_SegmentedGradeBucketAssignments_unlockedBody`);
|
||||
} else {
|
||||
logger.info(` ✓ Found details panel, extracting grades...`);
|
||||
|
||||
// Get all rows in the table
|
||||
const rows = await detailsPanel.$$('tr');
|
||||
logger.info(` Found ${rows.length} total rows`);
|
||||
|
||||
const grades = [];
|
||||
let currentGradeType = null; // 'major' or 'minor'
|
||||
|
||||
for (let j = 0; j < rows.length; j++) {
|
||||
const row = rows[j];
|
||||
if (!row) continue;
|
||||
|
||||
try {
|
||||
// Check if this row is a grade type header (Major Grades / Minor Grades)
|
||||
const gradeTypeSpan = await row.$('span.anchorText');
|
||||
|
||||
if (gradeTypeSpan) {
|
||||
const gradeTypeText = (await gradeTypeSpan.textContent())?.trim();
|
||||
logger.info(` Row ${j}: Found span with text "${gradeTypeText}"`);
|
||||
if (gradeTypeText === 'Major Grades') {
|
||||
currentGradeType = 'major';
|
||||
logger.info(` → Set current grade type to MAJOR`);
|
||||
continue;
|
||||
} else if (gradeTypeText === 'Minor Grades') {
|
||||
currentGradeType = 'minor';
|
||||
logger.info(` → Set current grade type to MINOR`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if we haven't found a grade type yet
|
||||
if (!currentGradeType) {
|
||||
logger.info(` Row ${j}: Skipping (no grade type set yet)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to extract grade data from this row
|
||||
// Look for any link in the row (could be linkDetails or other classes)
|
||||
const nameLink = await row.$('a');
|
||||
if (!nameLink) {
|
||||
logger.info(` Row ${j}: No link found, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = (await nameLink.textContent())?.trim() || '';
|
||||
logger.info(` Row ${j}: Found assignment "${name}"`);
|
||||
|
||||
let dueDate = '';
|
||||
let score = '';
|
||||
let pointsEarned = '';
|
||||
let attempts = '';
|
||||
|
||||
// Get all td cells
|
||||
const tdCells = await row.$$('td');
|
||||
logger.info(` Row ${j}: Found ${tdCells.length} td cells`);
|
||||
|
||||
// Due Date is typically in the second column (index 1)
|
||||
if (tdCells && tdCells.length > 1 && tdCells[1]) {
|
||||
dueDate = (await tdCells[1].textContent())?.trim() || '';
|
||||
}
|
||||
|
||||
// Score and Points are in disabled-link-column spans
|
||||
const disabledSpans = await row.$$('span.disabled-link-column');
|
||||
logger.info(` Row ${j}: Found ${disabledSpans.length} disabled-link-column spans`);
|
||||
|
||||
if (disabledSpans && disabledSpans.length >= 1 && disabledSpans[0]) {
|
||||
score = (await disabledSpans[0].textContent())?.trim() || '';
|
||||
}
|
||||
if (disabledSpans && disabledSpans.length >= 2 && disabledSpans[1]) {
|
||||
pointsEarned = (await disabledSpans[1].textContent())?.trim() || '';
|
||||
}
|
||||
|
||||
// Attempts - look for any number link in last columns
|
||||
const allLinks = await row.$$('a');
|
||||
const lastLink = allLinks && allLinks.length > 1 ? allLinks[allLinks.length - 1] : null;
|
||||
if (lastLink) {
|
||||
attempts = (await lastLink.textContent())?.trim() || '';
|
||||
}
|
||||
|
||||
const gradeEntry = {
|
||||
name: name,
|
||||
dueDate: score,
|
||||
score: pointsEarned,
|
||||
attempts: attempts,
|
||||
isMajorGrade: currentGradeType === 'major',
|
||||
};
|
||||
|
||||
grades.push(gradeEntry);
|
||||
logger.info(` ✓ ${gradeEntry.isMajorGrade ? 'MAJOR' : 'MINOR'}: ${name} | Due: ${dueDate} | Score: ${score} | Attempts: ${attempts}`);
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
logger.warn(` Row ${j}: Error processing row: ${errorMsg}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(` ✓ Extracted ${grades.length} grades for ${categoryText}`);
|
||||
|
||||
// Add to the global collection
|
||||
allClassGrades.push({
|
||||
className: className,
|
||||
teacher: teacher,
|
||||
period: period,
|
||||
category: categoryText,
|
||||
grades: grades,
|
||||
});
|
||||
|
||||
logger.info(` ✓ Added grades to collection (Total classes: ${allClassGrades.length})`);
|
||||
|
||||
// Take screenshot with category-based name
|
||||
// const screenshotName = `${categoryText}_${className.replace(/\s+/g, '_')}_row${dataRow}.png`.replace(/\s+/g, '_');
|
||||
// await page.screenshot({
|
||||
// path: `${SCREENSHOTS_DIR}/${screenshotName}`,
|
||||
// fullPage: true
|
||||
// });
|
||||
// logger.info(` 📸 Screenshot saved: ${screenshotName}`);
|
||||
}
|
||||
} else {
|
||||
logger.info(` ℹ Category "${categoryText}" not in target list, skipping`);
|
||||
}
|
||||
}
|
||||
|
||||
// Close the modal/panel
|
||||
const closeSelectors = [
|
||||
'button[aria-label="Close"]',
|
||||
'button[aria-label*="close"]',
|
||||
'button.close',
|
||||
'button[data-dismiss="modal"]',
|
||||
'button:has-text("Close")',
|
||||
'[aria-label="Close panel"]',
|
||||
'button.panel-close',
|
||||
];
|
||||
|
||||
let closed = false;
|
||||
for (const closeSelector of closeSelectors) {
|
||||
try {
|
||||
const closeButton = await page.$(closeSelector);
|
||||
if (closeButton) {
|
||||
const isCloseVisible = await closeButton.isVisible();
|
||||
if (isCloseVisible) {
|
||||
await closeButton.click();
|
||||
logger.info(` ✓ Closed panel with selector: ${closeSelector}`);
|
||||
closed = true;
|
||||
await page.waitForTimeout(1000);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!closed) {
|
||||
// Try pressing Escape key
|
||||
logger.info(` Trying Escape key to close panel`);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
// Small delay between cells
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error(` ✗ Error processing grade cell ${i + 1}: ${errorMsg}`);
|
||||
|
||||
// try {
|
||||
// await page.screenshot({
|
||||
// path: `${SCREENSHOTS_DIR}/grade_${i + 1}_ERROR.png`,
|
||||
// fullPage: true
|
||||
// });
|
||||
// } catch (screenshotErr) {
|
||||
// logger.warn(` Could not take error screenshot: ${screenshotErr}`);
|
||||
// }
|
||||
|
||||
// Try to close any open modals before continuing
|
||||
try {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(1000);
|
||||
} catch (closeErr) {
|
||||
logger.warn(` Could not close modal: ${closeErr}`);
|
||||
}
|
||||
|
||||
// Continue to next cell instead of crashing
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`\n🎉 Finished processing all grade cells!`);
|
||||
|
||||
// try {
|
||||
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/12-all-grades-complete.png`, fullPage: true });
|
||||
// } catch (err) {
|
||||
// logger.warn(`Could not take final screenshot: ${err}`);
|
||||
// }
|
||||
|
||||
// Write all grades to a JSON file
|
||||
try {
|
||||
const gradesFilePath = `${SCREENSHOTS_DIR}/all_grades.json`;
|
||||
writeFileSync(gradesFilePath, JSON.stringify(allClassGrades, null, 2));
|
||||
logger.info(`\n📄 Saved all grades to: ${gradesFilePath}`);
|
||||
logger.info(` Total classes processed: ${allClassGrades.length}`);
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
logger.error(`Failed to write grades file: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
1289
package-lock.json
generated
Normal file
1289
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "skyward-analysis",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/express": "^5.0.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
"pino": "^10.1.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"playwright": "^1.57.0"
|
||||
}
|
||||
}
|
||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user