(
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 |
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)}>${wrapperTag}>`
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 |
--------------------------------------------------------------------------------