diff --git a/config/config.go b/config/config.go index 8274561..4b43557 100644 --- a/config/config.go +++ b/config/config.go @@ -1,16 +1,27 @@ package config import ( + "log/slog" "os" + "strconv" "sync" + "time" ) type Config struct { - BaseURL string - Username string - Password string - Semester string - TWFID string + BaseURL string + Username string + Password string + Semester string + TWFID string + Recognizer RecognizerConfig +} + +type RecognizerConfig struct { + APIURL string + APIKey string + Model string + Timeout time.Duration } var ( @@ -22,10 +33,20 @@ func Load() *Config { cfgOnce.Do(func() { cfg = &Config{ BaseURL: getEnv("JWTS_BASE_URL", "http://jwts.hitwh.edu.cn"), - Username: getEnv("JWTS_USERNAME", ""), - Password: getEnv("JWTS_PASSWORD", ""), + Username: getEnv("JWTS_USERNAME", "2024212193"), + Password: getEnv("JWTS_PASSWORD", "wyb12580963"), Semester: getEnv("JWTS_SEMESTER", "2025-20262"), TWFID: getEnv("JWTS_TWFID", ""), + Recognizer: RecognizerConfig{ + APIURL: getEnv("RECOGNIZER_API_URL", "https://api.littlelan.cn/v1/chat/completions"), + APIKey: getEnv("RECOGNIZER_API_KEY", "sk-6064466699a996b6f83e1ae8247c1cb4eeb4b72ce23cb001204335a2567d63c1"), + Model: getEnv("RECOGNIZER_MODEL", "qwen3.5-plus"), + Timeout: getEnvDuration("RECOGNIZER_TIMEOUT", 120*time.Second), + }, + } + + if cfg.Recognizer.APIKey == "" { + slog.Warn("RECOGNIZER_API_KEY not set, image recognition will not work") } }) return cfg @@ -45,3 +66,36 @@ func getEnv(key, defaultValue string) string { } return val } + +func getEnvInt(key string, defaultValue int) int { + val := os.Getenv(key) + if val == "" { + return defaultValue + } + if i, err := strconv.Atoi(val); err == nil { + return i + } + return defaultValue +} + +func getEnvDuration(key string, defaultValue time.Duration) time.Duration { + val := os.Getenv(key) + if val == "" { + return defaultValue + } + if d, err := time.ParseDuration(val); err == nil { + return d + } + return defaultValue +} + +var ( + BaseURL = Get().BaseURL + Username = Get().Username + Password = Get().Password + Semester = Get().Semester + TWFID = Get().TWFID + RecognizerAPIURL = Get().Recognizer.APIURL + RecognizerAPIKey = Get().Recognizer.APIKey + RecognizerModel = Get().Recognizer.Model +) diff --git a/parser/empty_classroom.go b/parser/empty_classroom.go index 6d8b32d..68f591d 100644 --- a/parser/empty_classroom.go +++ b/parser/empty_classroom.go @@ -96,89 +96,106 @@ func parseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]Empty var classrooms []EmptyClassroomEntry - doc.Find("table").Each(func(_ int, table *goquery.Selection) { + // Find the main data table with class="dataTable" + doc.Find("table.dataTable").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() + // 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 + } + } }) - if !strings.Contains(headerText, "星期一") { + if !hasWeekdayHeader { 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()) - }) + // Build column mapping: column index -> (dayIndex, periodIndex) + colMapping := make(map[int][2]int) - colToDayPeriod := make(map[int][2]string) + // Start from column 1 (skip first column which is classroom name) 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} + colMapping[colIdx] = [2]int{dayIdx, periodIdx} 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) { + // Process data rows (skip first 2 header rows) + rows.Each(func(rowIdx int, row *goquery.Selection) { + if rowIdx < 2 { + return + } + cells := row.Find("td") if cells.Length() < 2 { return } + // Get classroom name from first cell classroomName := strings.TrimSpace(cells.First().Text()) + // Filter out invalid classroom names if classroomName == "" || len(classroomName) < 2 { return } - var slots []string - var weekdays []string - weekdaySet := make(map[string]bool) + // Skip non-classroom entries like "线上网络授课" + if strings.Contains(classroomName, "线上") || strings.Contains(classroomName, "网络") { + return + } + // Collect empty slots for this classroom + var slots []string + weekdaySet := make(map[int]bool) + var weekdays []string + + // Check each time slot column 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 - } + + // 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 }) - 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 - } + // 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) slots = append(slots, slot) - if !weekdaySet[dayName] { - weekdaySet[dayName] = true + + if !weekdaySet[dayIdx] { + weekdaySet[dayIdx] = true weekdays = append(weekdays, dayName) } } } + // Create entry if there are empty slots if len(slots) > 0 { weekStr := "" if weekStart != "" && weekEnd != "" { @@ -259,4 +276,9 @@ func fetchVenues(httpClient *http.Client, buildingCode string) ([]venueInfo, err } return venues, nil -} \ No newline at end of file +} + +// 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) +}