2 Commits

Author SHA1 Message Date
iluobei
29ebc7ea53 修改基础镜像 2026-03-19 15:18:52 +08:00
053725852a 增加自动获取节假日 2026-03-19 14:53:40 +08:00
11 changed files with 299 additions and 149 deletions

View File

@@ -1,4 +1,4 @@
FROM openjdk:8-alpine FROM eclipse-temurin:8-jdk-alpine
LABEL name="hoperun-custom-sign" LABEL name="hoperun-custom-sign"
MAINTAINER li@2ha.me MAINTAINER li@2ha.me
WORKDIR / WORKDIR /

View File

@@ -131,11 +131,6 @@
<artifactId>jsoup</artifactId> <artifactId>jsoup</artifactId>
<version>1.15.3</version> <version>1.15.3</version>
</dependency> </dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk8</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.jetbrains.kotlin</groupId> <groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-test</artifactId> <artifactId>kotlin-test</artifactId>

View File

@@ -1,6 +1,5 @@
package com.pomelotea.hoperun.sign package com.pomelotea.hoperun.sign
import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.boot.SpringApplication import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.SpringBootApplication
@@ -19,27 +18,6 @@ import java.util.*
@ConfigurationPropertiesScan @ConfigurationPropertiesScan
open class DakaApplication open class DakaApplication
val <T : Any> T.logger: Logger
get() = LoggerFactory.getLogger(this::class.java)
val holidays: List<String> = listOf(
// 元旦
"2025-01-01",
// 春节
"2025-01-28", "2025-01-29", "2025-01-30", "2025-01-31", "2025-02-01", "2025-02-02", "2025-02-03", "2025-02-04",
// 清明
"2025-04-04", "2025-04-05", "2025-04-06",
// 劳动节
"2025-05-01", "2025-05-02", "2025-05-05", "2025-05-04", "2025-05-05",
// 端午节
"2025-05-31", "2025-06-01", "2025-06-02",
// 中秋
// 国庆
"2025-10-01", "2025-10-02", "2025-10-03", "2025-10-04", "2025-10-05", "2025-10-06", "2025-10-07", "2025-10-08")
// 周末要上班的日期
val workdays: List<String> = listOf("2025-01-26", "2025-02-08", "2025-04-27", "2025-10-11", "2025-09-28", "2025-10-12")
private val logger = LoggerFactory.getLogger("APPLICATION-STARTER") private val logger = LoggerFactory.getLogger("APPLICATION-STARTER")
fun main(args: Array<String>) { fun main(args: Array<String>) {
SpringApplication.run(DakaApplication::class.java, *args) SpringApplication.run(DakaApplication::class.java, *args)

View File

@@ -8,7 +8,7 @@ import com.pomelotea.hoperun.sign.config.HoperunUserConfig.deviceMap
import com.pomelotea.hoperun.sign.config.HoperunUserConfig.getUserConfig import com.pomelotea.hoperun.sign.config.HoperunUserConfig.getUserConfig
import com.pomelotea.hoperun.sign.config.HoperunUserConfig.userConfigMap import com.pomelotea.hoperun.sign.config.HoperunUserConfig.userConfigMap
import com.pomelotea.hoperun.sign.config.UserConfig import com.pomelotea.hoperun.sign.config.UserConfig
import com.pomelotea.hoperun.sign.notify.ServerChan3NotifyHelper import com.pomelotea.hoperun.sign.holiday.HolidayService
import com.pomelotea.hoperun.sign.service.NotificationService import com.pomelotea.hoperun.sign.service.NotificationService
import com.pomelotea.hoperun.sign.scheduler.AutoDakaScheduler import com.pomelotea.hoperun.sign.scheduler.AutoDakaScheduler
import com.pomelotea.hoperun.sign.scheduler.AutoDakaScheduler.Companion.dakaQueue import com.pomelotea.hoperun.sign.scheduler.AutoDakaScheduler.Companion.dakaQueue
@@ -33,14 +33,14 @@ import java.util.*
@RestController @RestController
@RequestMapping("/api/daka") @RequestMapping("/api/daka")
class HoperunSignController( class HoperunSignController(
@field:Autowired private val notificationService: NotificationService @field:Autowired private val notificationService: NotificationService,
@field:Autowired private val holidayService: HolidayService
) { ) {
init { init {
AutoDakaScheduler(notificationService) AutoDakaScheduler(notificationService, holidayService)
// AutoRenewSessionScheduler()
val yxl = UserConfig( val yxl = UserConfig(
project_id = "U2103S000112", project_id = "U2103S000112",
projectcode = "U2103S000112", projectcode = "U2103S000112",
@@ -50,17 +50,11 @@ class HoperunSignController(
userConfigMap["16638"] = yxl userConfigMap["16638"] = yxl
} }
// @GetMapping("/username/{employeeNo}/{checked}/{jsessionId}")
@GetMapping("/username/{employeeNo}/{checked}") @GetMapping("/username/{employeeNo}/{checked}")
fun getUsername( fun getUsername(
@PathVariable employeeNo: String, @PathVariable employeeNo: String,
@PathVariable checked: Boolean @PathVariable checked: Boolean
): WebResult<LoginResponse?> { ): WebResult<LoginResponse?> {
/*
if (jsessionId != null) {
getJsessionIdAutoLogin(employeeNo, jsessionId)
}*/
val userConfig = getUserConfig(employeeNo).let { val userConfig = getUserConfig(employeeNo).let {
defaultLogin(employeeNo) defaultLogin(employeeNo)
getUserConfig(employeeNo) getUserConfig(employeeNo)

View File

@@ -7,18 +7,14 @@ import com.pomelotea.hoperun.sign.api.HoperunDakaRequest
import com.pomelotea.hoperun.sign.config.HoperunUserConfig import com.pomelotea.hoperun.sign.config.HoperunUserConfig
import com.pomelotea.hoperun.sign.config.HoperunUserConfig.getUserConfig import com.pomelotea.hoperun.sign.config.HoperunUserConfig.getUserConfig
import com.pomelotea.hoperun.sign.config.UserConfig import com.pomelotea.hoperun.sign.config.UserConfig
import com.pomelotea.hoperun.sign.holidays
import com.pomelotea.hoperun.sign.workdays
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.time.DayOfWeek
import java.time.Duration import java.time.Duration
import java.time.LocalDate import java.time.LocalDate
import java.time.ZoneOffset import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.* import java.util.*
/** /**
@@ -38,7 +34,6 @@ val client = OkHttpClient()
.newBuilder() .newBuilder()
.connectTimeout(Duration.ofSeconds(10)) .connectTimeout(Duration.ofSeconds(10))
.callTimeout(Duration.ofSeconds(10)) .callTimeout(Duration.ofSeconds(10))
// .addInterceptor(LogInterceptor())
.build() .build()
val sessionMap: MutableMap<String, String?> = HashMap() val sessionMap: MutableMap<String, String?> = HashMap()
@@ -57,26 +52,9 @@ fun getNowDateyyyy_MM_dd(): String {
return "${localDate.year}-${pad(localDate.monthValue)}-${pad(localDate.dayOfMonth)}" return "${localDate.year}-${pad(localDate.monthValue)}-${pad(localDate.dayOfMonth)}"
} }
fun getYesterdayyyyy_MM_dd(): String {
val localDate = LocalDate.now()
localDate.plusDays(-1)
return "${localDate.year}-${pad(localDate.monthValue)}-${pad(localDate.dayOfMonth)}"
}
private fun pad(num: Int): String { private fun pad(num: Int): String {
return if (num < 10) "0$num" else "$num" return if (num < 10) "0$num" else "$num"
} }
fun isWeekend(dakaDate: String): Boolean {
val date = LocalDate.parse(dakaDate, DateTimeFormatter.ISO_LOCAL_DATE)
return date.dayOfWeek == DayOfWeek.SATURDAY || date.dayOfWeek == DayOfWeek.SUNDAY
}
fun isWeekday(dakaDate: String): Boolean {
val date = LocalDate.parse(dakaDate, DateTimeFormatter.ISO_LOCAL_DATE)
return date.dayOfWeek == DayOfWeek.MONDAY || date.dayOfWeek == DayOfWeek.TUESDAY
|| date.dayOfWeek == DayOfWeek.WEDNESDAY || date.dayOfWeek == DayOfWeek.THURSDAY || date.dayOfWeek == DayOfWeek.FRIDAY
}
fun beginTime(employeeNo: String, date: String, time: String, jsessionId: String = sessionMap.get(employeeNo)!!): DakaResponse { fun beginTime(employeeNo: String, date: String, time: String, jsessionId: String = sessionMap.get(employeeNo)!!): DakaResponse {
val dakaRequest = Request.Builder() val dakaRequest = Request.Builder()
@@ -183,6 +161,3 @@ fun randowSecond(): String {
return if (second < 10) "0$second" else "$second" return if (second < 10) "0$second" else "$second"
} }
fun isNeedDaka(dakaDate: String): Boolean {
return workdays.contains(dakaDate) || (isWeekday(dakaDate) && !holidays.contains(dakaDate))
}

View File

@@ -0,0 +1,23 @@
package com.pomelotea.hoperun.sign.holiday
import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties(prefix = "holiday")
data class HolidayConfig(
var enabled: Boolean = true,
var sources: List<String> = listOf(
"https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/{year}.json"
),
var proxySources: List<String> = listOf(
"https://ghproxy.com/https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/{year}.json"
),
var cacheDir: String = "./data/holiday",
var cacheTtlDays: Long = 30,
var yearsAhead: Int = 1,
var fallback: Fallback = Fallback()
) {
data class Fallback(
var holidays: List<String> = emptyList(),
var workdays: List<String> = emptyList()
)
}

View File

@@ -0,0 +1,17 @@
package com.pomelotea.hoperun.sign.holiday
import com.alibaba.fastjson.annotation.JSONField
import java.time.Instant
data class HolidayEntry(
val name: String? = null,
val date: String? = null,
@field:JSONField(name = "isOffDay")
val isOffDay: Boolean? = null
)
data class HolidayCalendar(
val holidays: Set<String>,
val workdays: Set<String>,
val updatedAt: Instant
)

View File

@@ -0,0 +1,231 @@
package com.pomelotea.hoperun.sign.holiday
import com.alibaba.fastjson.JSON
import okhttp3.OkHttpClient
import okhttp3.Request
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.time.DayOfWeek
import java.time.Duration
import java.time.Instant
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.concurrent.ConcurrentHashMap
import javax.annotation.PostConstruct
@Service
class HolidayService(
private val config: HolidayConfig
) {
private val logger = LoggerFactory.getLogger(HolidayService::class.java)
private val cache = ConcurrentHashMap<Int, HolidayCalendar>()
private val dateFormatter = DateTimeFormatter.ISO_LOCAL_DATE
private val httpClient = OkHttpClient.Builder()
.connectTimeout(Duration.ofSeconds(10))
.callTimeout(Duration.ofSeconds(10))
.build()
@PostConstruct
fun preload() {
if (!config.enabled) {
return
}
val now = LocalDate.now()
val endYear = now.year + config.yearsAhead
logger.info("节假日配置预加载开始: years={}..{}", now.year, endYear)
for (year in now.year..endYear) {
runCatching { getCalendar(year) }
.onFailure { logger.warn("节假日配置预加载失败: year={}, err={}", year, it.message) }
}
}
fun isWorkingDay(dateStr: String): Boolean {
val date = LocalDate.parse(dateStr, dateFormatter)
return isWorkingDay(date)
}
fun isWorkingDay(date: LocalDate): Boolean {
if (!config.enabled) {
return defaultIsWorkingDay(date)
}
val dateKey = date.format(dateFormatter)
val calendar = getCalendar(date.year)
if (calendar.workdays.contains(dateKey)) {
return true
}
if (calendar.holidays.contains(dateKey)) {
return false
}
return defaultIsWorkingDay(date)
}
private fun defaultIsWorkingDay(date: LocalDate): Boolean {
return date.dayOfWeek != DayOfWeek.SATURDAY && date.dayOfWeek != DayOfWeek.SUNDAY
}
private fun getCalendar(year: Int): HolidayCalendar {
val now = Instant.now()
val cached = cache[year]
if (cached != null && !isExpired(cached.updatedAt, now)) {
return cached
}
val remoteResult = fetchRemote(year)
if (remoteResult != null) {
val calendar = applyFallback(parse(remoteResult.body)).copy(updatedAt = now)
cache[year] = calendar
writeCache(year, remoteResult.body)
logger.info(
"节假日配置加载成功(远程): year={}, url={}, holidays={}, workdays={}",
year,
remoteResult.url,
calendar.holidays.size,
calendar.workdays.size
)
return calendar
}
if (cached != null) {
val refreshed = cached.copy(updatedAt = now)
cache[year] = refreshed
logger.warn(
"节假日远程刷新失败,继续使用内存缓存: year={}, holidays={}, workdays={}",
year,
refreshed.holidays.size,
refreshed.workdays.size
)
return refreshed
}
val cachedRaw = readCache(year)
if (cachedRaw != null) {
val calendar = applyFallback(parse(cachedRaw)).copy(updatedAt = now)
cache[year] = calendar
logger.info(
"节假日配置加载成功(本地缓存): year={}, path={}, holidays={}, workdays={}",
year,
cachePath(year).toString(),
calendar.holidays.size,
calendar.workdays.size
)
return calendar
}
logger.warn("节假日配置缺失仅使用fallback: year={}", year)
val fallbackCalendar = applyFallback(HolidayCalendar(emptySet(), emptySet(), now))
cache[year] = fallbackCalendar
return fallbackCalendar
}
private fun isExpired(updatedAt: Instant, now: Instant): Boolean {
if (config.cacheTtlDays <= 0) {
return false
}
return Duration.between(updatedAt, now).toDays() >= config.cacheTtlDays
}
private fun fetchRemote(year: Int): FetchResult? {
val urls = buildUrls(year)
for (url in urls) {
try {
val request = Request.Builder().url(url).get().build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
logger.warn("节假日配置拉取失败: url={}, code={}", url, response.code)
return@use
}
val body = response.body?.string()
if (!body.isNullOrBlank()) {
return FetchResult(url = url, body = body)
}
logger.warn("节假日配置拉取为空: url={}", url)
}
} catch (ex: Exception) {
logger.warn("节假日配置拉取异常: url={}, err={}", url, ex.message)
}
}
return null
}
private fun buildUrls(year: Int): List<String> {
if (config.sources.isEmpty() && config.proxySources.isEmpty()) {
return emptyList()
}
val urls = ArrayList<String>()
config.sources.forEach { urls.add(it.replace("{year}", year.toString())) }
config.proxySources.forEach { urls.add(it.replace("{year}", year.toString())) }
return urls
}
private fun parse(raw: String): HolidayCalendar {
val entries = JSON.parseArray(raw, HolidayEntry::class.java) ?: emptyList()
val holidays = LinkedHashSet<String>()
val workdays = LinkedHashSet<String>()
for (entry in entries) {
val date = entry.date?.trim()
if (date.isNullOrEmpty()) {
continue
}
when (entry.isOffDay) {
true -> holidays.add(date)
false -> workdays.add(date)
else -> {}
}
}
return HolidayCalendar(holidays, workdays, Instant.now())
}
private fun applyFallback(calendar: HolidayCalendar): HolidayCalendar {
if (config.fallback.holidays.isEmpty() && config.fallback.workdays.isEmpty()) {
return calendar
}
val holidays = calendar.holidays.toMutableSet()
val workdays = calendar.workdays.toMutableSet()
for (date in config.fallback.holidays) {
holidays.add(date)
workdays.remove(date)
}
for (date in config.fallback.workdays) {
workdays.add(date)
holidays.remove(date)
}
return calendar.copy(holidays = holidays, workdays = workdays)
}
private fun cachePath(year: Int): Path {
return Paths.get(config.cacheDir).resolve("$year.json")
}
private fun writeCache(year: Int, raw: String) {
try {
val path = cachePath(year)
Files.createDirectories(path.parent)
Files.write(path, raw.toByteArray(StandardCharsets.UTF_8))
logger.info("节假日缓存写入成功: year={}, path={}", year, path.toString())
} catch (ex: Exception) {
logger.warn("节假日缓存写入失败: year={}, err={}", year, ex.message)
}
}
private fun readCache(year: Int): String? {
return try {
val path = cachePath(year)
if (!Files.exists(path)) {
return null
}
String(Files.readAllBytes(path), StandardCharsets.UTF_8)
} catch (ex: Exception) {
logger.warn("节假日缓存读取失败: year={}, err={}", year, ex.message)
null
}
}
private data class FetchResult(
val url: String,
val body: String
)
}

View File

@@ -1,21 +1,16 @@
package com.pomelotea.hoperun.sign.scheduler package com.pomelotea.hoperun.sign.scheduler
import com.alibaba.fastjson.JSON
import com.alibaba.fastjson.JSONObject
import com.pomelotea.hoperun.sign.api.DakaResponse import com.pomelotea.hoperun.sign.api.DakaResponse
import com.pomelotea.hoperun.sign.api.model.ScNotifyRequest
import com.pomelotea.hoperun.sign.api.model.ScNotifyResponse
import com.pomelotea.hoperun.sign.common.* import com.pomelotea.hoperun.sign.common.*
import com.pomelotea.hoperun.sign.config.HoperunUserConfig import com.pomelotea.hoperun.sign.config.HoperunUserConfig
import com.pomelotea.hoperun.sign.config.UserConfig import com.pomelotea.hoperun.sign.config.UserConfig
import com.pomelotea.hoperun.sign.notify.ServerChan3NotifyHelper import com.pomelotea.hoperun.sign.holiday.HolidayService
import com.pomelotea.hoperun.sign.service.NotificationService import com.pomelotea.hoperun.sign.service.NotificationService
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.slf4j.Logger import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.* import java.util.*
@@ -32,7 +27,8 @@ import java.util.concurrent.TimeUnit
* 自动打卡定时任务 * 自动打卡定时任务
**/ **/
open class AutoDakaScheduler( open class AutoDakaScheduler(
private val notificationService: NotificationService private val notificationService: NotificationService,
private val holidayService: HolidayService
) { ) {
val timeThreadPool: ScheduledExecutorService = Executors.newScheduledThreadPool(1) val timeThreadPool: ScheduledExecutorService = Executors.newScheduledThreadPool(1)
@@ -49,7 +45,7 @@ open class AutoDakaScheduler(
private fun addAutoDakaScheduled(dakaDate: String = getNowDateyyyy_MM_dd()) { private fun addAutoDakaScheduled(dakaDate: String = getNowDateyyyy_MM_dd()) {
// 调休的工作日 // 调休的工作日
if (isNeedDaka(dakaDate)) { if (holidayService.isWorkingDay(dakaDate)) {
HoperunUserConfig.userConfigMap.forEach { (_, v) -> HoperunUserConfig.userConfigMap.forEach { (_, v) ->
if (v.autoDaka) { if (v.autoDaka) {
// 没有当日的打卡信息才插入 // 没有当日的打卡信息才插入
@@ -98,11 +94,9 @@ open class AutoDakaScheduler(
schedulerThreadPool.schedule({ endDaka(it) }, endSeconds, TimeUnit.SECONDS) schedulerThreadPool.schedule({ endDaka(it) }, endSeconds, TimeUnit.SECONDS)
} }
} }
// schedulerThreadPool.schedule({beginDaka(it)}, 5, TimeUnit.SECONDS)
// schedulerThreadPool.schedule({ endDaka(it) }, 10, TimeUnit.SECONDS)
} }
dakaQueue.removeIf { it.dakaDate.compareTo(dakaDate) == -1 } dakaQueue.removeIf { it.dakaDate.compareTo(dakaDate) < 0 }
printScheduler() printScheduler()
dakaQueue.forEach { dakaQueue.forEach {
logger.info("Task: ${it.toCsv()}") logger.info("Task: ${it.toCsv()}")

View File

@@ -1,70 +0,0 @@
package com.pomelotea.hoperun.sign.scheduler
import com.pomelotea.hoperun.sign.common.client
import com.pomelotea.hoperun.sign.common.sessionMap
import com.pomelotea.hoperun.sign.logger
import okhttp3.Request
import org.jsoup.Jsoup
import java.util.*
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
/**
*
* @version 0.0.1
* @author jimlee
* date 2023-03-23 08:54
*
**/
class AutoRenewSessionScheduler {
val schedulerThreadPool: ScheduledExecutorService = Executors.newScheduledThreadPool(1)
init {
schedulerThreadPool.schedule({renewSession()}, 2, TimeUnit.MINUTES)
}
private fun renewSession() {
val iterator = sessionMap.iterator()
while (iterator.hasNext()) {
val item = iterator.next()
item.value?.let {
val result = index(it)
if (result != null) {
logger.info("[RENEW-SESSION]:SUCCESS:USER:$result")
} else {
logger.info("[REMOVE-SESSION]:USER:$result")
iterator.remove()
}
}
}
schedulerThreadPool.schedule({renewSession()}, (30 + Random().nextInt(30)).toLong(), TimeUnit.MINUTES)
}
private fun index(jsessionId: String): String? {
val indexRequest = Request.Builder()
.url("http://pom.hoperun.com:8187/attm/attence/getInfo")
.get()
.addHeader("Cookie", "JSESSIONID=$jsessionId")
.addHeader(
"User-Agent",
"Qing/0.9.113;iOS 16.3.1;Apple;iPhone13,2;deviceId:a8baf66f-fdeb-4f4d-b1e5-9fafcd5045b6;deviceName:iOS;clientId:10200;os:iOS 16.3.1;brand:Apple;model:iPhone13,2;lang:zh-CN;fontNum:0;fontScale:1.0;ver:10.7.14;Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"
)
.addHeader("accept", "*/*")
.addHeader("Origin", "http://pom.hoperun.com:8187")
.addHeader("Referer", "http://pom.hoperun.com:8187/attm/attence/getInfo")
.addHeader("content-type", "application/x-www-form-urlencoded; charset=UTF-8")
.build()
val indexResp = client.newCall(indexRequest).execute()
val indexHtml = indexResp.body?.string()
val username = try {
Jsoup.parse(indexHtml!!).select("#attendance-detail-content > div.container.none-padding > div > div:nth-child(1) > div:nth-child(4)")
.text()
} catch (e: Exception) {
null
}
return username
}
}

View File

@@ -41,3 +41,16 @@ hoperun:
sc3: sc3:
uid: 7248 uid: 7248
sendKey: sctp7248ta-yehg0lpo6cr9xl6ikqwbpn4l sendKey: sctp7248ta-yehg0lpo6cr9xl6ikqwbpn4l
holiday:
enabled: true
sources:
- https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/{year}.json
proxy-sources:
- https://ghproxy.com/https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/{year}.json
cache-dir: ./data/holiday
cache-ttl-days: 30
years-ahead: 1
fallback:
holidays: []
workdays: []