feat(grpc): add support for fetching empty classrooms
All checks were successful
Build / build (push) Successful in 1m31s
All checks were successful
Build / build (push) Successful in 1m31s
Implement the `TASK_TYPE_GET_EMPTY_CLASSROOM` task type to allow users to fetch available classroom information. - Add `TASK_TYPE_GET_EMPTY_CLASSROOM` to the `TaskType` enum in protobuf. - Define `GetEmptyClassroomPayload` and `EmptyClassroomResultData` messages. - Implement `getEmptyClassrooms`
This commit is contained in:
256
parser/empty_classroom.go
Normal file
256
parser/empty_classroom.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"schedule_converter/client"
|
||||
"schedule_converter/config"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
type EmptyClassroomEntry struct {
|
||||
Classroom string `json:"classroom"`
|
||||
Weekday string `json:"weekday"`
|
||||
Periods string `json:"periods"`
|
||||
Term string `json:"term"`
|
||||
Weeks string `json:"weeks"`
|
||||
}
|
||||
|
||||
type BuildingInfo struct {
|
||||
DM string `json:"dm"`
|
||||
MC string `json:"mc"`
|
||||
}
|
||||
|
||||
type VenueInfo struct {
|
||||
DM string `json:"dm"`
|
||||
MC string `json:"mc"`
|
||||
}
|
||||
|
||||
func FetchEmptyClassrooms(httpClient *http.Client, semester, weekStart, weekEnd, campusCode string) ([]EmptyClassroomEntry, error) {
|
||||
url := config.BaseURL + "/kjscx/queryKjs"
|
||||
fmt.Printf("[空教室] 正在获取空教室数据 (学期=%s, 周次=%s-%s)...\n", semester, weekStart, weekEnd)
|
||||
|
||||
// First GET to establish session
|
||||
resp, err := httpClient.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("访问空教室页面失败: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// POST with query parameters
|
||||
data := fmt.Sprintf("pageXnxq=%s&pageZc1=%s&pageZc2=%s&pageXiaoqu=%s",
|
||||
semester, weekStart, weekEnd, campusCode)
|
||||
req, err := http.NewRequest("POST", url, strings.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header = client.GetHeadersWithContentType("application/x-www-form-urlencoded")
|
||||
req.Header.Set("Referer", url)
|
||||
|
||||
resp, err = httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询空教室失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
html, err := decodeResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err := ParseEmptyClassroomHTML(html, semester, weekStart, weekEnd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fmt.Printf("[空教室] 共获取 %d 条空教室记录\n", len(entries))
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func ParseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]EmptyClassroomEntry, error) {
|
||||
if html == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Clean nbsp entities
|
||||
cleaned := strings.ReplaceAll(html, " ", "")
|
||||
cleaned = strings.ReplaceAll(cleaned, "&", "&")
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(cleaned))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dayNames := [7]string{"星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"}
|
||||
periodLabels := [6]string{"1,2", "3,4", "5,6", "7,8", "9,10", "11,12"}
|
||||
|
||||
var classrooms []EmptyClassroomEntry
|
||||
|
||||
doc.Find("table").Each(func(_ int, table *goquery.Selection) {
|
||||
rows := table.Find("tr")
|
||||
if rows.Length() < 3 {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this table has "星期一" in header
|
||||
headerText := ""
|
||||
table.Find("th").Each(func(_ int, th *goquery.Selection) {
|
||||
headerText += th.Text()
|
||||
})
|
||||
if !strings.Contains(headerText, "星期一") {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse period row (second row) to map column index to (day, period)
|
||||
periodRow := rows.Eq(1)
|
||||
periodCells := periodRow.Find("td")
|
||||
periodCellTexts := make([]string, periodCells.Length())
|
||||
periodCells.Each(func(i int, cell *goquery.Selection) {
|
||||
periodCellTexts[i] = strings.TrimSpace(cell.Text())
|
||||
})
|
||||
|
||||
colToDayPeriod := make(map[int][2]string)
|
||||
colIdx := 1
|
||||
for dayIdx := 0; dayIdx < 7; dayIdx++ {
|
||||
for periodIdx := 0; periodIdx < 6; periodIdx++ {
|
||||
var periodText string
|
||||
if colIdx < len(periodCellTexts) {
|
||||
periodText = periodCellTexts[colIdx]
|
||||
} else {
|
||||
periodText = periodLabels[periodIdx]
|
||||
}
|
||||
colToDayPeriod[colIdx] = [2]string{dayNames[dayIdx], periodText}
|
||||
colIdx++
|
||||
}
|
||||
}
|
||||
|
||||
// Data rows start from row 2+
|
||||
rows.FilterFunction(func(_ int, row *goquery.Selection) bool {
|
||||
return row.Index() >= 2
|
||||
}).Each(func(_ int, row *goquery.Selection) {
|
||||
cells := row.Find("td")
|
||||
if cells.Length() < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
classroomName := strings.TrimSpace(cells.First().Text())
|
||||
if classroomName == "" || len(classroomName) < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
var slots []string
|
||||
var weekdays []string
|
||||
weekdaySet := make(map[string]bool)
|
||||
|
||||
for colIdx := 1; colIdx < cells.Length(); colIdx++ {
|
||||
cell := cells.Eq(colIdx)
|
||||
// Check if cell has kjs_icon div (indicates occupied, not empty)
|
||||
hasIcon := false
|
||||
cell.Find("div").Each(func(_ int, div *goquery.Selection) {
|
||||
if classAttr, exists := div.Attr("class"); exists && strings.Contains(classAttr, "kjs_icon") {
|
||||
hasIcon = true
|
||||
}
|
||||
})
|
||||
|
||||
if !hasIcon && colToDayPeriod[colIdx][0] != "" {
|
||||
dayName := colToDayPeriod[colIdx][0]
|
||||
periodText := colToDayPeriod[colIdx][1]
|
||||
var slot string
|
||||
if periodText != "" {
|
||||
slot = fmt.Sprintf("%s 第%s节", dayName, periodText)
|
||||
} else {
|
||||
slot = dayName
|
||||
}
|
||||
slots = append(slots, slot)
|
||||
if !weekdaySet[dayName] {
|
||||
weekdaySet[dayName] = true
|
||||
weekdays = append(weekdays, dayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(slots) > 0 {
|
||||
weekStr := ""
|
||||
if weekStart != "" && weekEnd != "" {
|
||||
weekStr = fmt.Sprintf("%s-%s周", weekStart, weekEnd)
|
||||
}
|
||||
entry := EmptyClassroomEntry{
|
||||
Classroom: classroomName,
|
||||
Weekday: strings.Join(weekdays, ", "),
|
||||
Periods: strings.Join(slots, "\n"),
|
||||
Term: semester,
|
||||
Weeks: weekStr,
|
||||
}
|
||||
classrooms = append(classrooms, entry)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return classrooms, nil
|
||||
}
|
||||
|
||||
func FetchBuildings(httpClient *http.Client, campusCode string) ([]BuildingInfo, error) {
|
||||
url := config.BaseURL + "/kjscx/queryJxlListBySjid"
|
||||
fmt.Printf("[空教室] 正在获取楼号列表 (校区=%s)...\n", campusCode)
|
||||
|
||||
data := fmt.Sprintf("id=%s", campusCode)
|
||||
req, err := http.NewRequest("POST", url, strings.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header = client.GetHeadersWithContentType("application/x-www-form-urlencoded")
|
||||
req.Header.Set("Referer", config.BaseURL+"/kjscx/queryKjs")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取楼号列表失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("获取楼号列表失败, 状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var buildings []BuildingInfo
|
||||
if err := decodeJSONResponse(resp, &buildings); err != nil {
|
||||
return nil, fmt.Errorf("解析楼号列表失败: %w", err)
|
||||
}
|
||||
|
||||
return buildings, nil
|
||||
}
|
||||
|
||||
func FetchVenues(httpClient *http.Client, buildingCode string) ([]VenueInfo, error) {
|
||||
url := config.BaseURL + "/kjscx/queryJxcdListBySjid"
|
||||
fmt.Printf("[空教室] 正在获取场地列表 (楼号=%s)...\n", buildingCode)
|
||||
|
||||
data := fmt.Sprintf("id=%s", buildingCode)
|
||||
req, err := http.NewRequest("POST", url, strings.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header = client.GetHeadersWithContentType("application/x-www-form-urlencoded")
|
||||
req.Header.Set("Referer", config.BaseURL+"/kjscx/queryKjs")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取场地列表失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("获取场地列表失败, 状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var venues []VenueInfo
|
||||
if err := decodeJSONResponse(resp, &venues); err != nil {
|
||||
return nil, fmt.Errorf("解析场地列表失败: %w", err)
|
||||
}
|
||||
|
||||
return venues, nil
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package parser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -71,4 +72,12 @@ func isValidUTF8(data []byte) bool {
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func decodeJSONResponse(resp *http.Response, v any) error {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.NewDecoder(bytes.NewReader(body)).Decode(v)
|
||||
}
|
||||
Reference in New Issue
Block a user