Files
schedule_converter/parser/empty_classroom.go
lan accc6567f2
All checks were successful
Build / build (push) Successful in 5m28s
refactor(grpc): restructure task handling and migrate to slog
Refactor the gRPC client and task handling logic to improve modularity,
error handling, and observability.

- Split `grpc/handler.go` into specialized handler files (login,
  classroom, exams, grades, schedule) to reduce complexity.
- Replace standard `log` with `log/slog` throughout the project for
  structured logging.
- Refactor `grpc/client.go` to use a thread-safe connection management
  pattern with `sync.RWMutex`.
- Remove the legacy `service` package and HTTP server implementation,
  transitioning the application to a pure gRPC runner mode.
- Clean up `config` and `pkg/errors` to remove unused utilities and
  simplify the API.
- Improve error reporting in parsers and HTTP clients by checking
  response status codes.
2026-06-09 23:08:03 +08:00

262 lines
7.1 KiB
Go

package parser
import (
"fmt"
"log/slog"
"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.Get().BaseURL + "/kjscx/queryKjs"
log := slog.With("semester", semester, "week_start", weekStart, "week_end", weekEnd)
log.Info("获取空教室数据")
// 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()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("查询空教室失败, 状态码: %d", resp.StatusCode)
}
html, err := decodeResponseBody(resp)
if err != nil {
return nil, err
}
entries, err := parseEmptyClassroomHTML(html, semester, weekStart, weekEnd)
if err != nil {
return nil, err
}
log.Info("空教室数据获取完成", "count", 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, "&nbsp", "")
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.Get().BaseURL + "/kjscx/queryJxlListBySjid"
slog.Info("获取楼号列表", "campus", 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.Get().BaseURL+"/kjscx/queryKjs")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("获取楼号列表失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
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.Get().BaseURL + "/kjscx/queryJxcdListBySjid"
slog.Info("获取场地列表", "building", 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.Get().BaseURL+"/kjscx/queryKjs")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("获取场地列表失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
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
}