package tasks import ( "bufio" "encoding/json" "fmt" "io" "io/fs" "os" "path/filepath" "regexp" "strings" "github.com/engigu/baihu-panel/internal/constant" "github.com/engigu/baihu-panel/internal/database" "github.com/engigu/baihu-panel/internal/models" "github.com/engigu/baihu-panel/internal/utils" ) // regex patterns for script comment parsing var ( envRegex = regexp.MustCompile(`(?i)[new ]*Env\(['"]?([^'"]+)['"]?\)[;]?`) cronRegex = regexp.MustCompile(`(?i)(?:cron[ \t]*[:=]?[ \t]*['"]([^'"]+)['"])|(?:(?:^|[ \t\*\/])([0-9\*\/\-,L?]+[ \t]+[0-9\*\/\-,L?#]+[ \t]+[0-9\*\/\-,L?#]+[ \t]+[0-9\*\/\-,L?#]+[ \t]+[0-9\*\/\-,L?#]+(?:[ \t]+[0-9\*\/\-,L?#]+)?))`) ) // ParseRepoScriptsAndAddCron scans the repo dir for scripts, parses cron and env comments, and registers tasks func ParseRepoScriptsAndAddCron(es *ExecutorService, taskID string, logWriter io.Writer) { // help print logs to writer if provided log := func(format string, a ...interface{}) { msg := fmt.Sprintf(format, a...) if !strings.HasSuffix(msg, "\n") { msg += "\n" } if logWriter != nil { logWriter.Write([]byte(msg)) } // logger.Info(msg) } var repoTask models.Task res := database.DB.Where("id = ?", taskID).Limit(1).Find(&repoTask) if res.Error != nil || res.RowsAffected == 0 { return } if repoTask.Type != constant.TaskTypeRepo { return } var repoCfg models.RepoConfig if err := json.Unmarshal([]byte(repoTask.Config), &repoCfg); err != nil { return } if repoCfg.RepoSource != "ql" || !repoCfg.AutoAddCron { return } // target path targetPath := repoCfg.TargetPath if targetPath == "" { targetPath = repoTask.WorkDir } else if !filepath.IsAbs(targetPath) { targetPath = filepath.Join(resolveAbsScriptsDir(), targetPath) } if targetPath == "" { return } targetPath = filepath.Clean(targetPath) // We might have appended a repo id to targetPath repoId := utils.GetRepoIdentifier(repoCfg.SourceURL, repoCfg.Branch) gitDir := filepath.Join(targetPath, ".git") if !isDir(targetPath) || !pathExists(gitDir) { repoPath := filepath.Join(targetPath, repoId) if pathExists(repoPath) { targetPath = repoPath } } if !pathExists(targetPath) { return } // tag used during sync tag := fmt.Sprintf("%s", repoId) exts := []string{".js", ".py", ".ts", ".sh", ".php"} if repoCfg.Extensions != "" { customExts := splitKeywords(repoCfg.Extensions) if len(customExts) > 0 { exts = nil for _, e := range customExts { e = strings.TrimSpace(e) if e != "" { if !strings.HasPrefix(e, ".") { e = "." + e } exts = append(exts, e) } } } } log("\n----------------------------------------") log(" 开始扫描脚本并自动注册定时任务 ") log("----------------------------------------") foundSourceIDs := make(map[string]bool) newTaskCount := 0 updateTaskCount := 0 filepath.WalkDir(targetPath, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } if strings.Contains(path, ".git") { return nil } ext := filepath.Ext(path) validExt := false for _, e := range exts { if ext == e { validExt = true break } } if !validExt { return nil } f, err := os.Open(path) if err != nil { return nil } defer f.Close() var taskName string var taskCron string scanner := bufio.NewScanner(f) var firstCommentLine string inBlockComment := false // 特殊处理:针对当前文件名的 Cron 关联正则表达式 (对标青龙 perl 逻辑) // 寻找类似 "// 0 0 * * * jd_task.js" 的行 fileNameEscaped := regexp.QuoteMeta(filepath.Base(path)) associatedCronRegex := regexp.MustCompile(fmt.Sprintf(`(?i)(?:^|[ \t\*\//])(([0-9\*\/\-,L?#]+[ \t]+){4,5}[0-9\*\/\-,L?#]+)[ \t,"]+.*%s`, fileNameEscaped)) for i := 0; i < 150 && scanner.Scan(); i++ { // QL 扫描范围较大 line := strings.TrimSpace(scanner.Text()) if line == "" { continue } // 处理块注释开始/结束 if strings.HasPrefix(line, "/*") { inBlockComment = true line = strings.TrimPrefix(line, "/*") line = strings.TrimPrefix(line, "*") line = strings.TrimSpace(line) } if strings.HasSuffix(line, "*/") { inBlockComment = false line = strings.TrimSuffix(line, "*/") line = strings.TrimSpace(line) } // 1. 尝试提取任务名称 (优先使用 Env) if taskName == "" { if envMatch := envRegex.FindStringSubmatch(line); len(envMatch) > 1 { taskName = strings.TrimSpace(envMatch[1]) } else if strings.Contains(line, "name:") { // 兼容 name: "xxx" 格式 nameRegex := regexp.MustCompile(`(?i)name:[ \t]*['"]([^'"]+)['"]`) if nameMatch := nameRegex.FindStringSubmatch(line); len(nameMatch) > 1 { taskName = strings.TrimSpace(nameMatch[1]) } } } // 如果还没找到名称,且在注释中,记录第一行非空注释作为备选名称 if taskName == "" && (inBlockComment || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "*") || strings.HasPrefix(line, "#")) { cleanLine := line if strings.HasPrefix(line, "//") { cleanLine = strings.TrimPrefix(line, "//") } else if strings.HasPrefix(line, "#") { cleanLine = strings.TrimPrefix(line, "#") } else if strings.HasPrefix(line, "*") { cleanLine = strings.TrimPrefix(line, "*") } cleanLine = strings.TrimSpace(cleanLine) // 排除掉包含 "Env" 或 "cron" 的行, 且排除掉可能是路径或URL的行 if cleanLine != "" && !strings.Contains(strings.ToLower(cleanLine), "env") && !strings.Contains(strings.ToLower(cleanLine), "cron") && !strings.Contains(cleanLine, "http") && !strings.Contains(cleanLine, "/") && firstCommentLine == "" { // 且排除掉纯 cron 表达式 if !cronRegex.MatchString(cleanLine) { firstCommentLine = cleanLine } } } // 2. 提取 Cron if taskCron == "" { // A. 优先查找关联了当前文件名的 Cron (对标 QL) if assocMatch := associatedCronRegex.FindStringSubmatch(line); len(assocMatch) > 1 { taskCron = strings.TrimSpace(assocMatch[1]) } // B. 如果没找到,尝试普通的 cron: "..." 或 cron 表达式 if taskCron == "" { if cronMatch := cronRegex.FindStringSubmatch(line); len(cronMatch) > 0 { for _, m := range cronMatch[1:] { if m != "" { taskCron = strings.TrimSpace(m) break } } } } } if taskName != "" && taskCron != "" { break } } // 如果最后还是没找到 taskName,使用备选名称或文件名 if taskName == "" { if firstCommentLine != "" { taskName = firstCommentLine } else { taskName = strings.TrimSuffix(filepath.Base(path), ext) } } // 3. 应用白名单 / 黑名单过滤 (逻辑对标青龙) // relRepoPath 是相对于仓库根目录的路径,filename 是文件名 relRepoPath, _ := filepath.Rel(targetPath, path) filename := filepath.Base(path) // 只有在显式设置了白名单时才进行白名单校验 (青龙行为) if repoCfg.WhitelistPaths != "" { if !matchesQLPattern(relRepoPath, filename, repoCfg.WhitelistPaths) { return nil } } // 校验黑名单 if repoCfg.Blacklist != "" { if matchesQLPattern(relRepoPath, filename, repoCfg.Blacklist) { return nil } } if taskName != "" && taskCron != "" { // 获取脚本相对于数据目录的路径 absScriptsDir := resolveAbsScriptsDir() absTargetPath, _ := filepath.Abs(targetPath) absPath, _ := filepath.Abs(path) // 计算 SourceID: 相对于脚本目录的完整路径,并清洗特殊符号 relPath, _ := filepath.Rel(absScriptsDir, absPath) sourceID := sanitizeIdentifier(relPath) // 替换绝对路径为代号 $SCRIPTS_DIR$ displayPath := path displayWorkDir := targetPath if strings.HasPrefix(absPath, absScriptsDir) { if relCommandPath, err := filepath.Rel(absTargetPath, absPath); err == nil && relCommandPath != "" { displayPath = filepath.Clean(relCommandPath) } // 获取目录路径 relDir, _ := filepath.Rel(absScriptsDir, absTargetPath) displayWorkDir = filepath.Join("$SCRIPTS_DIR$", relDir) } // Found task, save it command := getCommandByExt(ext, displayPath) // 注册任务默认开启“全量环境变量注入”,以适配大多数脚本 defaultTaskConfig := `{"$task_all_envs":true}` // See if task exists (优先通过 SourceID 匹配) var existing models.Task tx := database.DB.Where("source_id = ? AND repo_task_id = ?", sourceID, repoTask.ID).Limit(1).Find(&existing) if tx.RowsAffected > 0 { // update existing.Name = taskName existing.Command = models.BigText(command) existing.Schedule = normalizeCron(taskCron) existing.Languages = repoTask.Languages existing.SourceID = sourceID existing.RepoTaskID = repoTask.ID existing.WorkDir = displayWorkDir // 如果原配置为空或者是 {},则应用默认配置 if string(existing.Config) == "" || string(existing.Config) == "{}" { existing.Config = models.BigText(defaultTaskConfig) } // 默认开启按条数清理30条 if existing.CleanConfig == "" { existing.CleanConfig = `{"type":"count","keep":30}` } database.DB.Save(&existing) if utils.DerefBool(existing.Enabled, true) && es != nil { es.AddCronTask(&existing) } log("[更新] 任务: %s (%s)", taskName, filename) updateTaskCount++ foundSourceIDs[sourceID] = true } else { // create new newTask := &models.Task{ Name: taskName, Command: models.BigText(command), Schedule: normalizeCron(taskCron), Type: "task", TriggerType: constant.TriggerTypeCron, Tags: tag, Languages: repoTask.Languages, Timeout: repoTask.Timeout, Config: models.BigText(defaultTaskConfig), Enabled: utils.BoolPtr(true), WorkDir: displayWorkDir, SourceID: sourceID, RepoTaskID: repoTask.ID, CleanConfig: `{"type":"count","keep":30}`, } newTask.ID = utils.GenerateID() database.DB.Create(newTask) if es != nil { es.AddCronTask(newTask) } log("[新增] 任务: %s (%s)", taskName, filename) newTaskCount++ foundSourceIDs[sourceID] = true } } return nil }) // 清理该仓库任务下不再存在的旧脚本任务 deletedTaskCount := 0 var oldTasks []models.Task if err := database.DB.Where("repo_task_id = ?", repoTask.ID).Find(&oldTasks).Error; err == nil { for _, ot := range oldTasks { if !foundSourceIDs[ot.SourceID] { log("[移除] 脚本已不存在,删除对应任务: %s", ot.Name) deletedTaskCount++ if es != nil { if es.taskService != nil { es.taskService.DeleteTask(ot.ID) } es.RemoveCronTask(ot.ID) } else { // Fallback if es is nil (which shouldn't happen, but just in case) database.DB.Unscoped().Where("id = ?", ot.ID).Delete(&models.Task{}) } } } } log("\n扫描完成: [新增 %d] [更新 %d] [移除 %d]", newTaskCount, updateTaskCount, deletedTaskCount) log("----------------------------------------") } func sanitizeIdentifier(s string) string { // 将所有非字母数字替换为下划线 reg := regexp.MustCompile(`[^a-zA-Z0-9]+`) res := reg.ReplaceAllString(s, "_") return strings.ToLower(strings.Trim(res, "_")) } func normalizeCron(cron string) string { fields := strings.Fields(cron) if len(fields) == 5 { return "0 " + cron } return cron } func pathExists(path string) bool { _, err := os.Stat(path) return err == nil } func isDir(path string) bool { info, err := os.Stat(path) if err != nil { return false } return info.IsDir() } func getCommandByExt(ext, path string) string { quotedPath := utils.QuotePath(path) switch ext { case ".js", ".ts": return fmt.Sprintf("node %s", quotedPath) case ".py": return fmt.Sprintf("python %s", quotedPath) case ".sh": return fmt.Sprintf("bash %s", quotedPath) case ".php": return fmt.Sprintf("php %s", quotedPath) } return quotedPath } func matchesQLPattern(rel, filename string, keywordsStr string) bool { if keywordsStr == "" { return false } keywords := splitKeywords(keywordsStr) for _, k := range keywords { // 1. 尝试作为正则整体进行匹配,默认不区分大小写 (?i) pattern := k if !strings.HasPrefix(pattern, "(?i)") { pattern = "(?i)" + pattern } reg, err := regexp.Compile(pattern) if err == nil { // 优先匹配文件名(解决 ^jd[^_] 这种锚点在相对路径下失效的问题) if reg.MatchString(filename) || reg.MatchString(rel) { return true } } else { // 回退逻辑:全小写包含判断 kLower := strings.ToLower(k) if strings.Contains(strings.ToLower(rel), kLower) || strings.Contains(strings.ToLower(filename), kLower) { return true } } } return false } func splitKeywords(s string) []string { if s == "" { return nil } var parts []string if strings.Contains(s, "|") { parts = strings.Split(s, "|") } else if strings.Contains(s, ",") { parts = strings.Split(s, ",") } else { parts = []string{s} } var res []string for _, p := range parts { p = strings.TrimSpace(p) if p != "" { res = append(res, p) } } return res }