├── .gitignore ├── .eslintrc.js ├── src ├── img │ ├── icon128.png │ ├── icon16.png │ └── icon48.png ├── components │ ├── index.ts │ ├── pityMsg.ts │ ├── app.ts │ ├── btn.ts │ ├── tabbale.ts │ ├── progress.ts │ ├── checkbox.ts │ └── table.ts ├── index.ts ├── extension │ ├── manifest.json │ ├── meta.js │ └── content.js ├── views │ ├── utils.ts │ ├── get.ts │ └── render.ts ├── utils.ts ├── State.ts ├── data.ts └── query.ts ├── renovate.json ├── .editorconfig ├── tsconfig.json ├── .github └── workflows │ ├── test.yml │ └── deploy.yml ├── README.md ├── package.json ├── LICENSE └── gulpfile.js /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | *.zip 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('ts-standardx/.eslintrc.js') 2 | -------------------------------------------------------------------------------- /src/img/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exuanbo/toefl-query-seats-enhance/HEAD/src/img/icon128.png -------------------------------------------------------------------------------- /src/img/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exuanbo/toefl-query-seats-enhance/HEAD/src/img/icon16.png -------------------------------------------------------------------------------- /src/img/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exuanbo/toefl-query-seats-enhance/HEAD/src/img/icon48.png -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "automerge": true, 4 | "major": { 5 | "automerge": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { App } from './app' 2 | export { Progress } from './progress' 3 | export { Table } from './table' 4 | export { Checkbox } from './checkbox' 5 | export * as Btn from './btn' 6 | export { PityMsg } from './pityMsg' 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | 5 | "strict": true, 6 | "strictNullChecks": false, 7 | 8 | "moduleResolution": "node", 9 | "esModuleInterop": true, 10 | 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/components/pityMsg.ts: -------------------------------------------------------------------------------- 1 | import { TemplateResult, html } from 'lit-html' 2 | 3 | export const PityMsg = (): TemplateResult => html` 4 |
5 | 6 | 真遗憾!没有找到可预定的考位😨
7 | 8 | ` 9 | -------------------------------------------------------------------------------- /src/components/app.ts: -------------------------------------------------------------------------------- 1 | import { Tabbale } from './tabbale' 2 | import { State } from '../State' 3 | import { TemplateResult, html } from 'lit-html' 4 | 5 | export const App = (state: State): TemplateResult => { 6 | return html` 7 |
8 | ${state.get('city') !== undefined 9 | ? html`
` 10 | : html`
${Tabbale(state)}
`} 11 | ` 12 | } 13 | -------------------------------------------------------------------------------- /src/components/btn.ts: -------------------------------------------------------------------------------- 1 | import { TemplateResult, html } from 'lit-html' 2 | 3 | export const expandBtn = (): TemplateResult => html` 4 | 13 | ` 14 | 15 | export const queryBtn = (): TemplateResult => html` 16 | 19 | ` 20 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { observeMutation, adjustStyle } from './views/utils' 2 | import * as render from './views/render' 3 | import { queryBtn } from './views/get' 4 | import { query } from './query' 5 | 6 | observeMutation( 7 | document.getElementById('wg_center'), 8 | () => { 9 | if (window.location.href.toString().split('#!')[1] === '/testSeat') { 10 | adjustStyle() 11 | render.checkbox() 12 | render.expandBtn() 13 | render.queryBtn() 14 | queryBtn.onClick(query) 15 | } 16 | }, 17 | { childList: true } 18 | ) 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: "test" 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | 11 | - uses: actions/setup-node@v2-beta 12 | with: 13 | node-version: "14" 14 | 15 | - uses: actions/cache@v2 16 | with: 17 | path: ~/.npm 18 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 19 | restore-keys: | 20 | ${{ runner.os }}-node- 21 | 22 | - run: npm ci 23 | 24 | - run: npm test 25 | -------------------------------------------------------------------------------- /src/extension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "托福考位查询增强", 4 | "version": "2.0.1", 5 | "description": "一键查询所选地点所有时间的可预定考位", 6 | "icons": { 7 | "16": "icon16.png", 8 | "48": "icon48.png", 9 | "128": "icon128.png" 10 | }, 11 | "browser_action": { 12 | "default_icon": "icon16.png" 13 | }, 14 | "content_scripts": [ 15 | { 16 | "matches": ["https://toefl.neea.cn/myHome/*"], 17 | "js": ["content.js"] 18 | } 19 | ], 20 | "permissions": ["https://toefl.neea.cn/myHome/*"], 21 | "web_accessible_resources": ["app.js"] 22 | } 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 托福考位查询增强 2 | 3 | [![Chrome Web Store](https://img.shields.io/chrome-web-store/v/efljijgnaedclinahimldbfebjnkafen?style=for-the-badge)](https://chrome.google.com/webstore/detail/efljijgnaedclinahimldbfebjnkafen) 4 | [![Greasy Fork](https://img.shields.io/badge/Greasy%20Fork-latest-brightgreen?style=for-the-badge)](https://greasyfork.org/zh-CN/scripts/411096-%E6%89%98%E7%A6%8F%E8%80%83%E4%BD%8D%E6%9F%A5%E8%AF%A2%E5%A2%9E%E5%BC%BA) 5 | 6 | 增强你的托福考位查询体验,支持查询多个城市。 7 | 8 | ## TO-DO 9 | 10 | - [ ] 代码重构 11 | - [ ] 增加日期范围选择 12 | - [ ] 美化城市标签 13 | 14 | ## License 15 | 16 | [MIT](https://github.com/exuanbo/toefl-query-seats-enhance/blob/master/LICENSE) 17 | -------------------------------------------------------------------------------- /src/extension/meta.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name 托福考位查询增强 3 | // @namespace https://github.com/exuanbo 4 | // @version 2.0.1 5 | // @author Exuanbo 6 | // @description 一键查询所选地点所有时间的可预定考位 7 | // @icon https://raw.githubusercontent.com/exuanbo/toefl-query-seats-enhance/master/src/img/icon48.png 8 | // @updateURL https://gist.github.com/exuanbo/32ebda63925b60e7817ba0346b980d14/raw/toefl-query-seats-enhance.user.js 9 | // @downloadURL https://gist.github.com/exuanbo/32ebda63925b60e7817ba0346b980d14/raw/toefl-query-seats-enhance.user.js 10 | // @match https://toefl.neea.cn/myHome/* 11 | // @grant none 12 | // ==/UserScript== 13 | -------------------------------------------------------------------------------- /src/extension/content.js: -------------------------------------------------------------------------------- 1 | const loadScript = source => { 2 | return new Promise((resolve, reject) => { 3 | const onloadHander = (_, isAbort) => { 4 | if ( 5 | isAbort || 6 | !script.readyState || 7 | /loaded|complete/.test(script.readyState) 8 | ) { 9 | script.onload = null 10 | script = undefined 11 | 12 | isAbort ? reject(new Error('Failed to load script')) : resolve() 13 | } 14 | } 15 | 16 | let script = document.createElement('script') 17 | script.src = source 18 | script.defer = true 19 | script.onload = onloadHander 20 | 21 | document.head.insertBefore(script, null) 22 | }) 23 | } 24 | 25 | loadScript(chrome.extension.getURL('app.js')) 26 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: "deploy" 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths-ignore: 8 | - "*.md" 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - uses: actions/setup-node@v2-beta 17 | with: 18 | node-version: "14" 19 | 20 | - uses: actions/cache@v2 21 | with: 22 | path: ~/.npm 23 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 24 | restore-keys: | 25 | ${{ runner.os }}-node- 26 | 27 | - run: npm ci 28 | 29 | - run: npm run build 30 | 31 | - uses: exuanbo/actions-deploy-gist@v1.0.3 32 | with: 33 | token: ${{ secrets.TOKEN }} 34 | gist_id: 32ebda63925b60e7817ba0346b980d14 35 | gist_file_name: toefl-query-seats-enhance.user.js 36 | file_path: ./dist/userscript/app.js 37 | -------------------------------------------------------------------------------- /src/views/utils.ts: -------------------------------------------------------------------------------- 1 | import { untilAvailable, forEachElOf } from '../utils' 2 | 3 | export const observeMutation = ( 4 | target: HTMLElement, 5 | callback: MutationCallback, 6 | config: MutationObserverInit 7 | ): void => { 8 | const observeThis = (): void => observeMutation(target, callback, config) 9 | 10 | if (!untilAvailable(target, observeThis)) { 11 | return 12 | } 13 | 14 | const observer = new MutationObserver(callback) 15 | observer.observe(target, config) 16 | } 17 | 18 | export const adjustStyle = (): void => { 19 | const formWrapper = document.getElementById('centerProvinceCity') 20 | .parentElement.parentElement 21 | const selects = document.querySelectorAll('.form-inline select') 22 | 23 | if (!untilAvailable(formWrapper !== null && selects, adjustStyle)) { 24 | return 25 | } 26 | 27 | formWrapper.classList.remove('offset1') 28 | formWrapper.style.textAlign = 'center' 29 | forEachElOf(selects, el => { 30 | el.style.width = '12em' 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "toefl-query-seats-enhance", 3 | "private": "true", 4 | "type": "module", 5 | "scripts": { 6 | "build": "gulp", 7 | "dev": "gulp server", 8 | "lint": "ts-standardx", 9 | "lint:fix": "ts-standardx --fix", 10 | "test": "npm run lint && npm run build" 11 | }, 12 | "author": "exuanbo", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@rollup/plugin-commonjs": "17.0.0", 16 | "@rollup/plugin-node-resolve": "11.0.0", 17 | "@rollup/plugin-typescript": "8.0.0", 18 | "@rollup/stream": "1.1.0", 19 | "@types/layui-layer": "1.0.0", 20 | "gulp": "4.0.2", 21 | "gulp-terser": "2.0.0", 22 | "rollup": "2.34.0", 23 | "rollup-plugin-cleanup": "3.2.1", 24 | "rollup-plugin-minify-html-template-literals": "1.2.0", 25 | "ts-standardx": "0.5.2", 26 | "tslib": "2.0.3", 27 | "typescript": "4.1.2", 28 | "vinyl-source-stream": "2.0.0" 29 | }, 30 | "dependencies": { 31 | "axios": "0.21.0", 32 | "lit-html": "1.3.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/components/tabbale.ts: -------------------------------------------------------------------------------- 1 | import { State } from '../State' 2 | import { TemplateResult, html } from 'lit-html' 3 | 4 | export const Tabbale = ({ get }: State): TemplateResult => { 5 | const cities = get('cities') 6 | 7 | return html` 8 | 19 |
20 | ${cities.map( 21 | city => html` 22 |
26 | ` 27 | )} 28 |
29 | ` 30 | } 31 | 32 | const translateCityName = (cityName: string): string => 33 | document.querySelector(`option[value="${cityName}"]`).innerHTML 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Exuanbo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export const firstKeyOf = (obj: object): string => Object.keys(obj)[0] 2 | 3 | export const calcLeft = (cur: string, arr: string[]): number => 4 | arr.length - arr.indexOf(cur) - 1 5 | 6 | export const sleep = async (ms: number): Promise => 7 | await new Promise(resolve => setTimeout(resolve, ms)) 8 | 9 | export const untilAvailable = ( 10 | el: any, 11 | fn: () => void, 12 | interval = 100 13 | ): boolean => { 14 | const isAvailable = Boolean(el) 15 | if (!isAvailable) { 16 | window.setTimeout(fn, interval) 17 | return false 18 | } 19 | return true 20 | } 21 | 22 | export const forEachElOf = ( 23 | nodeList: NodeListOf, 24 | cb: (node: T, index?: number) => void 25 | ): void => { 26 | nodeList.forEach((_, index) => { 27 | cb(nodeList[index], index) 28 | }) 29 | } 30 | 31 | export const mapElOf = ( 32 | nodeList: NodeListOf, 33 | cb: (node: El, index?: number) => T 34 | ): T[] => { 35 | return Array.from(nodeList).map(cb) 36 | } 37 | 38 | export const someElOf = ( 39 | nodeList: NodeListOf, 40 | cb: (node: T, index?: number) => boolean 41 | ): boolean => { 42 | return Array.from(nodeList).some(cb) 43 | } 44 | 45 | export const isMunicipality = (cityName: string): boolean => 46 | cityName === '北京' || 47 | cityName === '上海' || 48 | cityName === '天津' || 49 | cityName === '重庆' 50 | -------------------------------------------------------------------------------- /src/views/get.ts: -------------------------------------------------------------------------------- 1 | import { mapElOf } from '../utils' 2 | 3 | export const queryBtn = { 4 | getEl(): HTMLElement { 5 | return document.getElementById('queryBtn') 6 | }, 7 | 8 | onClick(fn: () => void | Promise): void { 9 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 10 | this.getEl().addEventListener('click', fn, { once: true }) 11 | } 12 | } 13 | 14 | export const selectedCity = (): string | string[] => { 15 | const checkboxes = document.querySelectorAll( 16 | 'input[type="checkbox"]' 17 | ) 18 | const checkedCities = mapElOf(checkboxes, (box): string => 19 | box.checked ? box.id : null 20 | ).filter(Boolean) 21 | const isExpanded = !document 22 | .getElementById('checkboxes') 23 | .classList.contains('hide') 24 | 25 | if (checkedCities.length > 0 && isExpanded) { 26 | return checkedCities 27 | } else { 28 | const selectedCity = document.getElementById( 29 | 'centerProvinceCity' 30 | ) as HTMLInputElement 31 | return selectedCity.value 32 | } 33 | } 34 | 35 | export const availableDates = (): string[] => { 36 | const options = document.getElementById('testDays') 37 | .childNodes as NodeListOf 38 | return mapElOf(options, (option): string => { 39 | const day = option.value 40 | if (day !== undefined && day !== '-1') { 41 | return day 42 | } 43 | }).filter(Boolean) 44 | } 45 | -------------------------------------------------------------------------------- /src/components/progress.ts: -------------------------------------------------------------------------------- 1 | import { State } from '../State' 2 | import { TemplateResult, html, nothing } from 'lit-html' 3 | import { styleMap } from 'lit-html/directives/style-map.js' 4 | 5 | export const Progress = ({ get }: State): TemplateResult => { 6 | const btn = document.getElementById('btnQuerySeat') 7 | const label = document.querySelector( 8 | 'label[for="centerProvinceCity"]' 9 | ) 10 | const barStyle = { 11 | margin: '1em auto 0', 12 | width: `${btn.offsetLeft - label.offsetLeft + label.offsetWidth}px` 13 | } 14 | const wellStyle = { 15 | ...barStyle, 16 | textAlign: 'center' 17 | } 18 | const barWidth = { 19 | width: `${get('isComplete') ? 100 : get('progress')}%` 20 | } 21 | 22 | return html` 23 |
24 |
25 | ${get('isComplete') 26 | ? html` 27 | 查询完成,找到 ${get('availableSeats')}个可预定考位${get( 28 | 'err' 29 | ) > 0 30 | ? html`。请求失败 ${get('err')}次` 31 | : nothing} 32 | ` 33 | : html` 34 | 正在查询中,剩余 ${get('cities') !== undefined 35 | ? html`${get('citiesLeft')}个城市 ` 36 | : nothing}${get('datesLeft')}个日期 37 | `} 38 |
39 |
44 |
45 |
46 |
47 | ` 48 | } 49 | -------------------------------------------------------------------------------- /src/State.ts: -------------------------------------------------------------------------------- 1 | import { calcLeft } from './utils' 2 | import * as render from './views/render' 3 | import { selectedCity, availableDates } from './views/get' 4 | 5 | class StateData { 6 | city?: string 7 | cities?: string[] 8 | currentCity: string 9 | citiesLeft: number 10 | 11 | dates: string[] 12 | currentDate: string 13 | datesLeft: number 14 | 15 | sum?: number 16 | progress = 0 17 | 18 | availableSeats = 0 19 | err = 0 20 | isComplete = false 21 | 22 | constructor() { 23 | this.dates = availableDates() 24 | 25 | const city = selectedCity() 26 | if (city instanceof Array && city.length !== 1) { 27 | this.cities = city 28 | } else if (city === '-1') { 29 | return 30 | } else { 31 | const singleCity = city instanceof Array ? city[0] : city 32 | this.city = singleCity 33 | } 34 | 35 | this.sum = 36 | this.dates.length * (this.city !== undefined ? 1 : this.cities.length) 37 | } 38 | } 39 | 40 | export class State { 41 | private data = new StateData() 42 | 43 | get =

(prop: P): StateData[P] | undefined => { 44 | return this.data[prop] 45 | } 46 | 47 | set = (newData: Partial, update = true): void => { 48 | Object.assign(this.data, newData) 49 | if (update) { 50 | this.update() 51 | } 52 | } 53 | 54 | private update(): void { 55 | if (this.data.cities !== undefined) { 56 | this.data.citiesLeft = calcLeft(this.data.currentCity, this.data.cities) 57 | } 58 | this.data.datesLeft = calcLeft(this.data.currentDate, this.data.dates) 59 | this.data.progress = 60 | 100 - 61 | (((this.data.cities !== undefined 62 | ? this.data.citiesLeft * this.data.dates.length 63 | : 0) + 64 | this.data.datesLeft) / 65 | this.data.sum) * 66 | 100 67 | render.progress(this) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import { exec } from 'child_process' 3 | import path from 'path' 4 | 5 | import gulp from 'gulp' 6 | import through from 'through2' 7 | import source from 'vinyl-source-stream' 8 | import terser from 'gulp-terser' 9 | 10 | import rollupStream from '@rollup/stream' 11 | import { nodeResolve } from '@rollup/plugin-node-resolve' 12 | import commonjs from '@rollup/plugin-commonjs' 13 | import typescript from '@rollup/plugin-typescript' 14 | import minifyHtml from 'rollup-plugin-minify-html-template-literals' 15 | import cleanup from 'rollup-plugin-cleanup' 16 | 17 | const { src, dest, series, parallel, watch } = gulp 18 | 19 | const header = filePath => 20 | through.obj((file, _, callback) => { 21 | const headerContent = String( 22 | fs.readFileSync(path.join(process.cwd(), filePath)) 23 | ) 24 | file.contents = Buffer.from(headerContent + String(file.contents)) 25 | callback(null, file) 26 | }) 27 | 28 | const clean = () => exec('rm -rf dist') 29 | 30 | const build = () => { 31 | const options = { 32 | input: 'src/index.ts', 33 | output: { format: 'iife' }, 34 | plugins: [ 35 | nodeResolve({ browser: true }), 36 | commonjs(), 37 | typescript(), 38 | minifyHtml(), 39 | cleanup() 40 | ] 41 | } 42 | return rollupStream(options) 43 | .pipe(source('app.js')) 44 | .pipe(dest('dist/extension')) 45 | } 46 | 47 | const addHeader = () => 48 | src('dist/extension/app.js') 49 | .pipe(header('src/extension/meta.js')) 50 | .pipe(dest('dist/userscript')) 51 | 52 | const minifyJS = () => 53 | src('dist/extension/app.js', { base: '.' }).pipe(terser()).pipe(dest('.')) 54 | 55 | const mix = () => 56 | src([ 57 | 'src/extension/content.js', 58 | 'src/extension/manifest.json', 59 | 'src/img/*' 60 | ]).pipe(dest('dist/extension')) 61 | 62 | const pack = () => { 63 | const name = 'extension' 64 | return exec( 65 | `cd dist/${name} && zip -r ${name}.zip . && mv ${name}.zip ../${name}.zip` 66 | ) 67 | } 68 | 69 | const server = () => { 70 | watch('src/**/*', { ignoreInitial: false }, build) 71 | } 72 | 73 | export default series( 74 | clean, 75 | parallel(series(build, parallel(addHeader, minifyJS)), mix), 76 | pack 77 | ) 78 | export { server } 79 | -------------------------------------------------------------------------------- /src/data.ts: -------------------------------------------------------------------------------- 1 | import { State } from './State' 2 | import axios from 'axios' 3 | 4 | /** 5 | * @example 6 | * { 7 | "status":true, 8 | "testDate":"2020年12月20日 星期日", 9 | "testSeats":{ 10 | "09:00|20201220|08:30":[ 11 | { 12 | "provinceCn":"甘肃", 13 | "provinceEn":"GANSU", 14 | "cityCn":"兰州", 15 | "cityEn":"LANZHOU", 16 | "centerCode":"STN80013A", 17 | "centerNameCn":"兰州大学", 18 | "centerNameEn":"LANZHOU UNIVERSITY", 19 | "testFee":210000, 20 | "lateReg":"N", 21 | "seatStatus":1, 22 | "seatBookStatus":0, 23 | "rescheduleDeadline":1608134399000, 24 | "cancelDeadline":1608134399000, 25 | "testTime":"09:00", 26 | "lateRegFlag":"N" 27 | } 28 | ] 29 | }, 30 | "lateRegFee":31000 31 | } 32 | */ 33 | 34 | export interface QueryData { 35 | status: boolean 36 | testDate: string 37 | testSeats: { 38 | [key: string]: SeatDetail[] 39 | } 40 | lateRegFee: number 41 | availableSeats?: number 42 | } 43 | 44 | export interface SeatDetail { 45 | provinceCn: string 46 | cityCn: string 47 | centerCode: string 48 | centerNameCn: string 49 | testFee: number 50 | lateReg: 'N' | 'Y' 51 | seatStatus: -1 | 1 52 | seatBookStatus: -1 | 1 53 | testTime: string 54 | lateRegFlag: 'N' | 'Y' 55 | } 56 | 57 | type filteredData = QueryData | null 58 | 59 | export const getData = async ({ get }: State): Promise => { 60 | const city = get('currentCity') 61 | const testDay = get('currentDate') 62 | const { data } = await axios.get('testSeat/queryTestSeats', { 63 | params: { city: city, testDay: testDay } 64 | }) 65 | 66 | return filterSeats(data) 67 | } 68 | 69 | const filterSeats = (data: QueryData): filteredData => { 70 | if (data.status) { 71 | const dataDate = Object.keys(data.testSeats)[0] 72 | const seatDetails = data.testSeats[dataDate] 73 | 74 | const filtered = seatDetails.filter(seatDetail => seatDetail.seatStatus) 75 | const availableSeats = filtered.length 76 | 77 | if (availableSeats > 0) { 78 | data.testSeats[dataDate] = filtered 79 | data.availableSeats = availableSeats 80 | return data 81 | } 82 | } 83 | 84 | return null 85 | } 86 | -------------------------------------------------------------------------------- /src/query.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from './utils' 2 | import { queryBtn } from './views/get' 3 | import * as render from './views/render' 4 | import { State } from './State' 5 | import { getData } from './data' 6 | 7 | export const query = async (): Promise => { 8 | const state = new State() 9 | const { get, set } = state 10 | 11 | if (get('city') === undefined && get('cities') === undefined) { 12 | layer.msg('请选择考点所在城市', { time: 2000, icon: 0 }) 13 | queryBtn.onClick(query) 14 | return 15 | } 16 | 17 | await start() 18 | 19 | async function start(): Promise { 20 | queryBtn.getEl().innerText = '停止当前查询' 21 | queryBtn.onClick(end) 22 | render.app(state) 23 | 24 | if (get('city') !== undefined) { 25 | set({ currentCity: get('city') }) 26 | await single() 27 | } else { 28 | await multi() 29 | } 30 | 31 | end() 32 | } 33 | 34 | function end(): void { 35 | set({ isComplete: true }) 36 | queryBtn.getEl().innerText = '查询全部日期' 37 | queryBtn.onClick(query) 38 | } 39 | 40 | async function multi(): Promise { 41 | for (const city of get('cities')) { 42 | set({ currentCity: city }) 43 | 44 | await single() 45 | 46 | if (get('isComplete')) { 47 | break 48 | } 49 | if (get('citiesLeft') > 0) { 50 | await sleep(2000) 51 | } 52 | } 53 | } 54 | 55 | async function single(): Promise { 56 | const initialSeatsNum = get('availableSeats') 57 | 58 | for (const testDay of get('dates')) { 59 | set({ currentDate: testDay }) 60 | 61 | try { 62 | const data = await getData(state) 63 | if (data !== null) { 64 | render.table(data, state) 65 | set( 66 | { availableSeats: get('availableSeats') + data.availableSeats }, 67 | false 68 | ) 69 | } 70 | } catch { 71 | set({ err: get('err') + 1 }, false) 72 | } 73 | 74 | if (get('isComplete')) { 75 | break 76 | } 77 | if (get('datesLeft') > 0) { 78 | await sleep(2000) 79 | } 80 | } 81 | 82 | if ( 83 | get('cities') !== undefined && 84 | get('availableSeats') === initialSeatsNum 85 | ) { 86 | render.pityMsg(state) 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/components/checkbox.ts: -------------------------------------------------------------------------------- 1 | import { forEachElOf, mapElOf, someElOf, isMunicipality } from '../utils' 2 | import { TemplateResult, html, nothing } from 'lit-html' 3 | 4 | export const Checkbox = (): TemplateResult => html` 5 | 9 | 全选/全不选 10 | 11 | ${loopProvinceGroup()} 12 | ` 13 | 14 | const toggleCheck = (): void => { 15 | const allCheckboxes = document.querySelectorAll( 16 | 'input[type="checkbox"]' 17 | ) 18 | const notAllChecked = someElOf(allCheckboxes, box => !box.checked) 19 | forEachElOf(allCheckboxes, box => { 20 | box.checked = notAllChecked 21 | }) 22 | } 23 | 24 | const loopProvinceGroup = (): TemplateResult[] => { 25 | const provinceGroups = document.querySelectorAll( 26 | '#centerProvinceCity optgroup' 27 | ) 28 | 29 | return mapElOf( 30 | provinceGroups, 31 | (provinceGroup): TemplateResult => { 32 | const provinceName = provinceGroup.label 33 | const cities = provinceGroup.childNodes as NodeListOf 34 | 35 | return html` 36 |

37 | ${mapElOf( 38 | cities, 39 | (city, index): TemplateResult => html` 40 | ${isMunicipality(city.label) 41 | ? nothing 42 | : html` 43 | ${index === 0 44 | ? html` 45 | ${provinceName}: 52 | ` 53 | : nothing} 54 | `} 68 | ` 69 | )} 70 |
71 | ` 72 | } 73 | ) 74 | } 75 | -------------------------------------------------------------------------------- /src/components/table.ts: -------------------------------------------------------------------------------- 1 | import { firstKeyOf, isMunicipality } from '../utils' 2 | import { QueryData, SeatDetail } from '../data' 3 | import { TemplateResult, html, nothing } from 'lit-html' 4 | import { styleMap } from 'lit-html/directives/style-map.js' 5 | 6 | export const Table = ({ 7 | testDate, 8 | testSeats 9 | }: QueryData): TemplateResult => html` 10 | 11 | 12 | 13 | 考试日期:${testDate}考试时间:${firstKeyOf(testSeats).split('|')[0]}最晚到达时间:${firstKeyOf(testSeats).split('|')[2]} 22 | 23 | 24 | 25 | 城市 26 | 考点 27 | 费用
(RMB¥) 28 | 考位 29 | 30 | 31 | 32 | ${testSeats[firstKeyOf(testSeats)].map( 33 | (seat: SeatDetail): TemplateResult => html`${rowTpl(seat)}` 34 | )} 35 | 36 | ` 37 | 38 | const rowTpl = (seat: SeatDetail): TemplateResult => html` 39 | 40 | 41 | ${isMunicipality(seat.provinceCn) 42 | ? html`${seat.cityCn}` 43 | : html`${seat.provinceCn} ${seat.cityCn}`} 44 | 45 | 46 | ${seat.centerCode}${seat.centerNameCn} 54 | 55 | 56 | ${seat.lateRegFlag === 'Y' 57 | ? html`*` 58 | : nothing} 59 | ${formatCurrency(seat.testFee / 100)} 60 | ${seat.lateRegFlag === 'Y' ? html`
(已包含逾期费附加费)` : nothing} 61 | 62 | 63 | ${seat.seatStatus === -1 64 | ? '已截止' 65 | : seat.seatStatus === 1 66 | ? '有名额' 67 | : '名额暂满'} 68 | 69 | 70 | ` 71 | 72 | const stylesMiddle = { 73 | textAlign: 'center', 74 | verticalAlign: 'middle' 75 | } 76 | 77 | const formatCurrency = (value: number): string => 'RMB¥' + value.toFixed(2) 78 | -------------------------------------------------------------------------------- /src/views/render.ts: -------------------------------------------------------------------------------- 1 | import { untilAvailable } from '../utils' 2 | import { QueryData } from '../data' 3 | import { State } from '../State' 4 | import { App, Progress, Table, Checkbox, Btn, PityMsg } from '../components' 5 | import { TemplateResult, render, nothing } from 'lit-html' 6 | 7 | export const app = (state: State): void => { 8 | document.getElementById('checkboxes').classList.add('hide') 9 | const wrapper = document.getElementById('qrySeatResult') 10 | render(nothing, wrapper) 11 | render(App(state), wrapper) 12 | } 13 | 14 | export const progress = (state: State): void => { 15 | const wrapper = document.getElementById('progressWrapper') 16 | if (wrapper !== null) { 17 | render(Progress(state), wrapper) 18 | } 19 | } 20 | 21 | export const table = (data: QueryData, { get }: State): void => { 22 | insertComponent({ 23 | component: Table(data), 24 | wrapperTag: 'table', 25 | wrapperAttr: { 26 | id: `${get('currentCity')}[${get('currentDate')}]`, 27 | class: 'table table-bordered', 28 | style: 'margin-top:12px;font-size:16px;' 29 | }, 30 | target: document.getElementById( 31 | `${get('city') !== undefined ? 'tables' : `tab-${get('currentCity')}`}` 32 | ), 33 | position: 'beforeend' 34 | }) 35 | } 36 | 37 | export const checkbox = (): void => { 38 | const provinceGroup = document.querySelectorAll( 39 | '#centerProvinceCity optgroup' 40 | ) 41 | const provinceNum = provinceGroup.length 42 | 43 | if (!untilAvailable(provinceNum, checkbox)) { 44 | return 45 | } 46 | if ( 47 | !untilAvailable(provinceGroup[provinceNum - 1].label === '浙江', checkbox) 48 | ) { 49 | return 50 | } 51 | 52 | const selectCity = document.getElementById('centerProvinceCity') 53 | const formWrapper = selectCity.parentElement.parentElement.parentElement 54 | 55 | insertComponent({ 56 | component: Checkbox(), 57 | wrapperTag: 'div', 58 | wrapperAttr: { 59 | id: 'checkboxes', 60 | class: 'hide well', 61 | style: `max-width:fit-content;margin:4px 0 0 ${ 62 | selectCity.offsetLeft - selectCity.parentElement.offsetLeft 63 | }px;padding:1em;` 64 | }, 65 | target: formWrapper, 66 | position: 'beforeend' 67 | }) 68 | } 69 | 70 | export const expandBtn = (): void => { 71 | insertComponent({ 72 | component: Btn.expandBtn(), 73 | wrapperAttr: { id: 'expandBtnWrapper' }, 74 | target: document.getElementById('centerProvinceCity') 75 | }) 76 | } 77 | 78 | export const queryBtn = (): void => { 79 | insertComponent({ 80 | component: Btn.queryBtn(), 81 | wrapperAttr: { id: 'queryBtnWrapper' }, 82 | target: document.getElementById('expandBtn') 83 | }) 84 | } 85 | 86 | export const pityMsg = (state: State): void => { 87 | render(PityMsg(), document.getElementById(`tab-${state.get('currentCity')}`)) 88 | } 89 | 90 | interface insertOptions { 91 | component: TemplateResult 92 | wrapperTag?: string 93 | wrapperAttr: { 94 | id: string 95 | [Attr: string]: string 96 | } 97 | target: HTMLElement 98 | position?: string 99 | } 100 | 101 | function insertComponent({ 102 | component, 103 | wrapperTag = 'span', 104 | wrapperAttr, 105 | target, 106 | position = 'afterend' 107 | }: insertOptions): void { 108 | target.insertAdjacentHTML( 109 | position as InsertPosition, 110 | `<${wrapperTag} ${loopAttr(wrapperAttr)}>` 111 | ) 112 | render(component, document.getElementById(wrapperAttr.id)) 113 | 114 | function loopAttr(attrs: typeof wrapperAttr): string { 115 | return Object.keys(attrs) 116 | .map(attr => `${attr}="${attrs[attr]}"`) 117 | .join(' ') 118 | } 119 | } 120 | --------------------------------------------------------------------------------