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, " ", "") 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 }