查询空教室功能测试通过
All checks were successful
Build / build (push) Successful in 1m46s

This commit is contained in:
WuYuuuub
2026-06-12 20:52:18 +08:00
parent accc6567f2
commit 1cd0c655f8
2 changed files with 129 additions and 53 deletions

View File

@@ -1,8 +1,11 @@
package config package config
import ( import (
"log/slog"
"os" "os"
"strconv"
"sync" "sync"
"time"
) )
type Config struct { type Config struct {
@@ -11,6 +14,14 @@ type Config struct {
Password string Password string
Semester string Semester string
TWFID string TWFID string
Recognizer RecognizerConfig
}
type RecognizerConfig struct {
APIURL string
APIKey string
Model string
Timeout time.Duration
} }
var ( var (
@@ -22,10 +33,20 @@ func Load() *Config {
cfgOnce.Do(func() { cfgOnce.Do(func() {
cfg = &Config{ cfg = &Config{
BaseURL: getEnv("JWTS_BASE_URL", "http://jwts.hitwh.edu.cn"), BaseURL: getEnv("JWTS_BASE_URL", "http://jwts.hitwh.edu.cn"),
Username: getEnv("JWTS_USERNAME", ""), Username: getEnv("JWTS_USERNAME", "2024212193"),
Password: getEnv("JWTS_PASSWORD", ""), Password: getEnv("JWTS_PASSWORD", "wyb12580963"),
Semester: getEnv("JWTS_SEMESTER", "2025-20262"), Semester: getEnv("JWTS_SEMESTER", "2025-20262"),
TWFID: getEnv("JWTS_TWFID", ""), 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 return cfg
@@ -45,3 +66,36 @@ func getEnv(key, defaultValue string) string {
} }
return val 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
)

View File

@@ -96,89 +96,106 @@ func parseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]Empty
var classrooms []EmptyClassroomEntry 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") rows := table.Find("tr")
if rows.Length() < 3 { if rows.Length() < 3 {
return return
} }
// Check if this table has "星期一" in header // Verify this is the classroom table by checking for weekday headers
headerText := "" hasWeekdayHeader := false
table.Find("th").Each(func(_ int, th *goquery.Selection) { rows.First().Find("th").Each(func(_ int, th *goquery.Selection) {
headerText += th.Text() text := strings.TrimSpace(th.Text())
for _, day := range dayNames {
if strings.Contains(text, day) {
hasWeekdayHeader = true
break
}
}
}) })
if !strings.Contains(headerText, "星期一") { if !hasWeekdayHeader {
return return
} }
// Parse period row (second row) to map column index to (day, period) // Build column mapping: column index -> (dayIndex, periodIndex)
periodRow := rows.Eq(1) colMapping := make(map[int][2]int)
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) // Start from column 1 (skip first column which is classroom name)
colIdx := 1 colIdx := 1
for dayIdx := 0; dayIdx < 7; dayIdx++ { for dayIdx := 0; dayIdx < 7; dayIdx++ {
for periodIdx := 0; periodIdx < 6; periodIdx++ { for periodIdx := 0; periodIdx < 6; periodIdx++ {
var periodText string colMapping[colIdx] = [2]int{dayIdx, periodIdx}
if colIdx < len(periodCellTexts) {
periodText = periodCellTexts[colIdx]
} else {
periodText = periodLabels[periodIdx]
}
colToDayPeriod[colIdx] = [2]string{dayNames[dayIdx], periodText}
colIdx++ colIdx++
} }
} }
// Data rows start from row 2+ // Process data rows (skip first 2 header rows)
rows.FilterFunction(func(_ int, row *goquery.Selection) bool { rows.Each(func(rowIdx int, row *goquery.Selection) {
return row.Index() >= 2 if rowIdx < 2 {
}).Each(func(_ int, row *goquery.Selection) { return
}
cells := row.Find("td") cells := row.Find("td")
if cells.Length() < 2 { if cells.Length() < 2 {
return return
} }
// Get classroom name from first cell
classroomName := strings.TrimSpace(cells.First().Text()) classroomName := strings.TrimSpace(cells.First().Text())
// Filter out invalid classroom names
if classroomName == "" || len(classroomName) < 2 { if classroomName == "" || len(classroomName) < 2 {
return return
} }
var slots []string // Skip non-classroom entries like "线上网络授课"
var weekdays []string if strings.Contains(classroomName, "线上") || strings.Contains(classroomName, "网络") {
weekdaySet := make(map[string]bool) 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++ { for colIdx := 1; colIdx < cells.Length(); colIdx++ {
cell := cells.Eq(colIdx) cell := cells.Eq(colIdx)
// Check if cell has kjs_icon div (indicates occupied, not empty)
hasIcon := false // Get mapping for this column
cell.Find("div").Each(func(_ int, div *goquery.Selection) { mapping, exists := colMapping[colIdx]
if classAttr, exists := div.Attr("class"); exists && strings.Contains(classAttr, "kjs_icon") { if !exists {
hasIcon = true 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] != "" { // Also check if cell has any content
dayName := colToDayPeriod[colIdx][0] cellText := strings.TrimSpace(cell.Text())
periodText := colToDayPeriod[colIdx][1] if cellText != "" {
var slot string isEmpty = false
if periodText != "" {
slot = fmt.Sprintf("%s 第%s节", dayName, periodText)
} else {
slot = dayName
} }
if isEmpty {
dayName := dayNames[dayIdx]
periodText := periodLabels[periodIdx]
slot := fmt.Sprintf("%s 第%s节", dayName, periodText)
slots = append(slots, slot) slots = append(slots, slot)
if !weekdaySet[dayName] {
weekdaySet[dayName] = true if !weekdaySet[dayIdx] {
weekdaySet[dayIdx] = true
weekdays = append(weekdays, dayName) weekdays = append(weekdays, dayName)
} }
} }
} }
// Create entry if there are empty slots
if len(slots) > 0 { if len(slots) > 0 {
weekStr := "" weekStr := ""
if weekStart != "" && weekEnd != "" { if weekStart != "" && weekEnd != "" {
@@ -260,3 +277,8 @@ func fetchVenues(httpClient *http.Client, buildingCode string) ([]venueInfo, err
return venues, nil return venues, nil
} }
// 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)
}