2026-06-02 13:59:30 +08:00
|
|
|
package parser
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
2026-06-09 23:08:03 +08:00
|
|
|
"log/slog"
|
2026-06-02 13:59:30 +08:00
|
|
|
"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"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 23:08:03 +08:00
|
|
|
type buildingInfo struct {
|
2026-06-02 13:59:30 +08:00
|
|
|
DM string `json:"dm"`
|
|
|
|
|
MC string `json:"mc"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 23:08:03 +08:00
|
|
|
type venueInfo struct {
|
2026-06-02 13:59:30 +08:00
|
|
|
DM string `json:"dm"`
|
|
|
|
|
MC string `json:"mc"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func FetchEmptyClassrooms(httpClient *http.Client, semester, weekStart, weekEnd, campusCode string) ([]EmptyClassroomEntry, error) {
|
2026-06-09 23:08:03 +08:00
|
|
|
url := config.Get().BaseURL + "/kjscx/queryKjs"
|
|
|
|
|
log := slog.With("semester", semester, "week_start", weekStart, "week_end", weekEnd)
|
|
|
|
|
log.Info("获取空教室数据")
|
2026-06-02 13:59:30 +08:00
|
|
|
|
|
|
|
|
// 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()
|
|
|
|
|
|
2026-06-09 23:08:03 +08:00
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
return nil, fmt.Errorf("查询空教室失败, 状态码: %d", resp.StatusCode)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 13:59:30 +08:00
|
|
|
html, err := decodeResponseBody(resp)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 23:08:03 +08:00
|
|
|
entries, err := parseEmptyClassroomHTML(html, semester, weekStart, weekEnd)
|
2026-06-02 13:59:30 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 23:08:03 +08:00
|
|
|
log.Info("空教室数据获取完成", "count", len(entries))
|
2026-06-02 13:59:30 +08:00
|
|
|
return entries, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 23:08:03 +08:00
|
|
|
func parseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]EmptyClassroomEntry, error) {
|
2026-06-02 13:59:30 +08:00
|
|
|
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
|
|
|
|
|
|
2026-06-12 20:52:18 +08:00
|
|
|
// Find the main data table with class="dataTable"
|
|
|
|
|
doc.Find("table.dataTable").Each(func(_ int, table *goquery.Selection) {
|
2026-06-02 13:59:30 +08:00
|
|
|
rows := table.Find("tr")
|
|
|
|
|
if rows.Length() < 3 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 20:52:18 +08:00
|
|
|
// Verify this is the classroom table by checking for weekday headers
|
|
|
|
|
hasWeekdayHeader := false
|
|
|
|
|
rows.First().Find("th").Each(func(_ int, th *goquery.Selection) {
|
|
|
|
|
text := strings.TrimSpace(th.Text())
|
|
|
|
|
for _, day := range dayNames {
|
|
|
|
|
if strings.Contains(text, day) {
|
|
|
|
|
hasWeekdayHeader = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-02 13:59:30 +08:00
|
|
|
})
|
2026-06-12 20:52:18 +08:00
|
|
|
if !hasWeekdayHeader {
|
2026-06-02 13:59:30 +08:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 20:52:18 +08:00
|
|
|
// Build column mapping: column index -> (dayIndex, periodIndex)
|
|
|
|
|
colMapping := make(map[int][2]int)
|
2026-06-02 13:59:30 +08:00
|
|
|
|
2026-06-12 20:52:18 +08:00
|
|
|
// Start from column 1 (skip first column which is classroom name)
|
2026-06-02 13:59:30 +08:00
|
|
|
colIdx := 1
|
|
|
|
|
for dayIdx := 0; dayIdx < 7; dayIdx++ {
|
|
|
|
|
for periodIdx := 0; periodIdx < 6; periodIdx++ {
|
2026-06-12 20:52:18 +08:00
|
|
|
colMapping[colIdx] = [2]int{dayIdx, periodIdx}
|
2026-06-02 13:59:30 +08:00
|
|
|
colIdx++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 20:52:18 +08:00
|
|
|
// Process data rows (skip first 2 header rows)
|
|
|
|
|
rows.Each(func(rowIdx int, row *goquery.Selection) {
|
|
|
|
|
if rowIdx < 2 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 13:59:30 +08:00
|
|
|
cells := row.Find("td")
|
|
|
|
|
if cells.Length() < 2 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 20:52:18 +08:00
|
|
|
// Get classroom name from first cell
|
2026-06-02 13:59:30 +08:00
|
|
|
classroomName := strings.TrimSpace(cells.First().Text())
|
2026-06-12 20:52:18 +08:00
|
|
|
// Filter out invalid classroom names
|
2026-06-02 13:59:30 +08:00
|
|
|
if classroomName == "" || len(classroomName) < 2 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 20:52:18 +08:00
|
|
|
// Skip non-classroom entries like "线上网络授课"
|
|
|
|
|
if strings.Contains(classroomName, "线上") || strings.Contains(classroomName, "网络") {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Collect empty slots for this classroom
|
2026-06-02 13:59:30 +08:00
|
|
|
var slots []string
|
2026-06-12 20:52:18 +08:00
|
|
|
weekdaySet := make(map[int]bool)
|
2026-06-02 13:59:30 +08:00
|
|
|
var weekdays []string
|
|
|
|
|
|
2026-06-12 20:52:18 +08:00
|
|
|
// Check each time slot column
|
2026-06-02 13:59:30 +08:00
|
|
|
for colIdx := 1; colIdx < cells.Length(); colIdx++ {
|
|
|
|
|
cell := cells.Eq(colIdx)
|
2026-06-12 20:52:18 +08:00
|
|
|
|
|
|
|
|
// Get mapping for this column
|
|
|
|
|
mapping, exists := colMapping[colIdx]
|
|
|
|
|
if !exists {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
dayIdx := mapping[0]
|
|
|
|
|
periodIdx := mapping[1]
|
|
|
|
|
|
|
|
|
|
// Check if cell is empty (no kjs_icon div)
|
|
|
|
|
isEmpty := true
|
|
|
|
|
cell.Find("div.kjs_icon").Each(func(_ int, div *goquery.Selection) {
|
|
|
|
|
isEmpty = false
|
2026-06-02 13:59:30 +08:00
|
|
|
})
|
|
|
|
|
|
2026-06-12 20:52:18 +08:00
|
|
|
// Also check if cell has any content
|
|
|
|
|
cellText := strings.TrimSpace(cell.Text())
|
|
|
|
|
if cellText != "" {
|
|
|
|
|
isEmpty = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if isEmpty {
|
|
|
|
|
dayName := dayNames[dayIdx]
|
|
|
|
|
periodText := periodLabels[periodIdx]
|
|
|
|
|
slot := fmt.Sprintf("%s 第%s节", dayName, periodText)
|
2026-06-02 13:59:30 +08:00
|
|
|
slots = append(slots, slot)
|
2026-06-12 20:52:18 +08:00
|
|
|
|
|
|
|
|
if !weekdaySet[dayIdx] {
|
|
|
|
|
weekdaySet[dayIdx] = true
|
2026-06-02 13:59:30 +08:00
|
|
|
weekdays = append(weekdays, dayName)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 20:52:18 +08:00
|
|
|
// Create entry if there are empty slots
|
2026-06-02 13:59:30 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 23:08:03 +08:00
|
|
|
func fetchBuildings(httpClient *http.Client, campusCode string) ([]buildingInfo, error) {
|
|
|
|
|
url := config.Get().BaseURL + "/kjscx/queryJxlListBySjid"
|
|
|
|
|
slog.Info("获取楼号列表", "campus", campusCode)
|
2026-06-02 13:59:30 +08:00
|
|
|
|
|
|
|
|
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")
|
2026-06-09 23:08:03 +08:00
|
|
|
req.Header.Set("Referer", config.Get().BaseURL+"/kjscx/queryKjs")
|
2026-06-02 13:59:30 +08:00
|
|
|
|
|
|
|
|
resp, err := httpClient.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("获取楼号列表失败: %w", err)
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
2026-06-09 23:08:03 +08:00
|
|
|
if resp.StatusCode != http.StatusOK {
|
2026-06-02 13:59:30 +08:00
|
|
|
return nil, fmt.Errorf("获取楼号列表失败, 状态码: %d", resp.StatusCode)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 23:08:03 +08:00
|
|
|
var buildings []buildingInfo
|
2026-06-02 13:59:30 +08:00
|
|
|
if err := decodeJSONResponse(resp, &buildings); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("解析楼号列表失败: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return buildings, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 23:08:03 +08:00
|
|
|
func fetchVenues(httpClient *http.Client, buildingCode string) ([]venueInfo, error) {
|
|
|
|
|
url := config.Get().BaseURL + "/kjscx/queryJxcdListBySjid"
|
|
|
|
|
slog.Info("获取场地列表", "building", buildingCode)
|
2026-06-02 13:59:30 +08:00
|
|
|
|
|
|
|
|
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")
|
2026-06-09 23:08:03 +08:00
|
|
|
req.Header.Set("Referer", config.Get().BaseURL+"/kjscx/queryKjs")
|
2026-06-02 13:59:30 +08:00
|
|
|
|
|
|
|
|
resp, err := httpClient.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("获取场地列表失败: %w", err)
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
2026-06-09 23:08:03 +08:00
|
|
|
if resp.StatusCode != http.StatusOK {
|
2026-06-02 13:59:30 +08:00
|
|
|
return nil, fmt.Errorf("获取场地列表失败, 状态码: %d", resp.StatusCode)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 23:08:03 +08:00
|
|
|
var venues []venueInfo
|
2026-06-02 13:59:30 +08:00
|
|
|
if err := decodeJSONResponse(resp, &venues); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("解析场地列表失败: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return venues, nil
|
2026-06-12 20:52:18 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestParseEmptyClassroomHTML is a test helper function that exports the parser for testing
|
|
|
|
|
func TestParseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]EmptyClassroomEntry, error) {
|
|
|
|
|
return parseEmptyClassroomHTML(html, semester, weekStart, weekEnd)
|
|
|
|
|
}
|