每天早上最怕的不是起床,而是进了公司忘了打卡。我们公司的考勤规则很硬:上班卡必须在 09:00:59 之前打,晚一秒就算迟到。
钉钉本身有「极速打卡」:只要人在考勤范围内、打开钉钉,它就会自动帮你打上。所以问题就简化成了一件事——到点、到地方,让手机自己把钉钉打开;万一没打开,再用强提醒把我叫住。
这篇文章记录我是怎么用 iPhone 自带的 快捷指令(Shortcuts) 加免费 App Scriptable 搭出来的,以及中间踩过的几个坑。最后附完整脚本,复制就能用。
一、整体思路:一条自动化 + 一个快捷指令 + 一个脚本

三个角色各干一件事:
- 自动化(Automation):负责「什么时候触发」。一条自动化里放 4 个触发条件:到达公司、8:58、17:30、17:34。
- 快捷指令「打卡」:负责「打开钉钉」。它只做两步:运行脚本;如果脚本输出
open,就打开钉钉。 - Scriptable 脚本:负责「该不该打开」。判断今天是不是工作日、是不是打卡时段、人在不在公司、今天打过没有,并且写日志。
为什么打开钉钉要交给快捷指令,而不是脚本自己打开?这就是下面要说的第一个坑。
二、一天里会发生什么

| 时间 | 触发 | 脚本做什么 |
|---|---|---|
| 8:00–9:00 到达公司 | 正常打卡(auto) | 定位确认在公司 200 米内 → 记录 → 打开钉钉,极速打卡自动完成 |
| 8:58 | 兜底检查(check) | 今天已记录就什么都不做;没记录就连响 3 次强提醒,并打开钉钉 |
| 17:30 | 正常打卡(auto) | 人还在公司就打开钉钉打下班卡 |
| 17:34 | 兜底检查(check) | 没有下班记录就强提醒 |
脚本不需要任何参数,它看当前时间自己判断模式:08:58–09:30 或 17:34 之后是兜底检查,其他时候是正常打卡。周末、节假日自动跳过;调休上班的周末照常执行(读的是系统日历里的「中国大陆节假日」,看当天有没有「班」或「休」)。
三、脚本是怎么判断的

检查顺序是刻意排过的:越便宜的检查越靠前。看时间几乎不花时间,读一个小文件也很快,读日历稍慢,GPS 定位最慢也最耗电。绝大多数「不该打卡」的情况在前两步就结束了,根本不会去定位。
「今天打过没有」靠的是 iCloud Drive 里 Scriptable/打卡/ 目录下的两个小文件:上班.txt、下班.txt,内容就是打开钉钉的时间,比如 2026-10-30 08:43。每一步判断还会追加到 log.txt,哪天不灵了翻日志就知道卡在哪一步。
四、踩过的坑
坑 1:后台运行的脚本打不开别的 App
最开始我让 Scriptable 自己用 Safari.open("dingtalk://") 打开钉钉。手动点运行没问题,可一放进自动化就不灵了——自动化里的脚本是在后台跑的,iOS 不允许后台脚本跳转到别的 App。
解决办法:脚本只负责「说」要不要打开(Script.setShortcutOutput("open")),真正打开钉钉的动作交给快捷指令自带的 Open App。
坑 2:离公司明明 0 米,脚本却说 359 米

公司坐标一开始是从地图 App 上点出来的,结果坐在工位上跑脚本,日志写着「离公司 359 米,不在范围」。
原因是国内地图用的是 GCJ-02 加密坐标,而 iPhone 定位 API 返回的是国际通用的 WGS-84,两套坐标在国内会差出几百米。最省事的解决办法:人坐在工位上,用 Scriptable 跑一次 Location.current(),把打印出来的经纬度直接填进配置。顺手把范围从 100 米放宽到 200 米,留点余量。
坑 3:If 判断的是 Shortcut Input,永远进不了分支
快捷指令里第一次写 If 的时候,变量默认选成了 Shortcut Input(自动化传进来的东西),而不是脚本的输出,于是永远不会打开钉钉。要改成判断 Run Script 的输出(显示为 Output)。
坑 4:一条自动化可以有多个触发
以前 iOS 一条自动化只能有一个触发条件,我一度建了好几条。新版 iOS 的时间触发可以用 or 加多个时间,到达地点也能并进同一条。合并后记得把旧的单条自动化删掉,否则会重复触发。
坑 5:拿不到钉钉里真实的打卡结果
iOS 不允许一个 App 读另一个 App 的数据,钉钉也没把打卡记录开放给快捷指令。所以脚本记录的是「这个时段钉钉被打开过」,而不是「打卡成功了」。好在开了极速打卡,打开钉钉基本就等于打上了卡;钉钉打卡成功也会推一条通知,看一眼就放心。
如果你是自己手动打的卡,在强提醒通知上长按,点「✅ 已打卡」,脚本就会记下来,后面的提醒不再响。
五、动手搭建(英文界面)
1. 安装 Scriptable,新建脚本
App Store 安装 Scriptable,点 + 新建脚本,命名为 打卡,把文末的完整脚本贴进去,把 office 换成你工位实测的经纬度(见坑 2)。
2. 新建快捷指令「打卡」
打开 Shortcuts,在 Shortcuts 标签页点 +:
- 添加 Scriptable → Run Script,Script 选
打卡;点 > 展开,关掉 Run In App 和 Show When Run。 - 顶部的 Receive … from … 一行保持默认即可(自动化通过 Run Shortcut 调用时不受它影响)。
- 添加 If,变量选上一步的输出 Output,条件选 is,值填
open(没有 is 的话选 has any value 也行,脚本只在需要时才有输出)。 - 在 If 和 End If 之间添加 Open App,App 选 DingTalk。Otherwise 留空。
3. 新建一条自动化
切到 Automation 标签页点 +:
- 触发选 Time of Day,设 At 8:58 AM,再用 or 加上 5:30 PM、5:34 PM,Repeat 选 Daily。
- 再加一个 Arrive 触发,Location 在工位上选 Current Location,半径约 200 m,Time Range 设 8:00–9:00 AM。
- 选 Run Immediately,关掉 Notify When Run。
- 动作选 Run Shortcut → 打卡,Input 留空。
4. 测一下
在 iCloud Drive 的 Scriptable/打卡/ 里删掉 上班.txt,把脚本里的 onBefore 临时改成 1000,手动运行快捷指令「打卡」。能跳进钉钉、log.txt 多一行记录,就成功了。测完记得改回 901。
六、完整脚本
// =====================================================================
// 打卡.js v4:钉钉上下班打卡提醒 + 兜底检查(Scriptable)
//
// 由一条自动化(到达公司 / 17:30 / 8:58 / 17:34)调用快捷指令「打卡」,
// 模式按时间自动判断,不需要传参数:
// auto 正常打卡:在范围内就记录,并让快捷指令打开钉钉
// check 兜底检查(08:58–09:30、17:34 之后):今天未记录就强提醒
// done 手动标记已打卡(通知上的「✅ 已打卡」按钮)
// 输出:需要打开钉钉时,Script.setShortcutOutput("open")
// =====================================================================
// ---------- 配置 ----------
const CONFIG = {
office: { lat: 0.000000, lng: 0.000000 }, // 公司坐标:换成你在工位实测的 GPS
radius: 200, // 有效范围(米)
calendar: "中国大陆节假日", // 节假日日历名
workKw: "班", // 调休上班关键词
restKw: "休", // 放假关键词
onBefore: 901, // 上班卡截止:09:00:59 前有效
offAfter: 1730, // 下班卡开始 HHMM
checkOn: [858, 930], // 上班兜底时段 [起, 止)
checkOff: 1734, // 下班兜底开始 HHMM
repeat: [1, 2], // 兜底强提醒追加(分钟后)
}
// ---------- 运行环境 ----------
const now = new Date()
const pad = n => String(n).padStart(2, "0")
const TODAY = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
const HM = now.getHours() * 100 + now.getMinutes()
const HM_STR = `${pad(now.getHours())}:${pad(now.getMinutes())}`
const KIND = HM < 1200 ? "上班" : "下班"
// 模式:手动按钮传 done;否则按时间判断
const MODE = args.queryParameters?.mode
|| ((HM >= CONFIG.checkOn[0] && HM < CONFIG.checkOn[1]) || HM >= CONFIG.checkOff
? "check" : "auto")
const fm = FileManager.iCloud()
const DIR = fm.joinPath(fm.documentsDirectory(), "打卡")
const LOG = fm.joinPath(DIR, "log.txt")
const FLAG = fm.joinPath(DIR, `${KIND}.txt`)
const DONE_URL = `scriptable:///run/${encodeURIComponent(Script.name())}?mode=done`
if (!fm.fileExists(DIR)) fm.createDirectory(DIR)
// ---------- 工具函数 ----------
/** 追加一行日志,只保留最近 200 行 */
function log(msg) {
const line = `${TODAY} ${HM_STR} [${MODE}] ${msg}`
console.log(line)
const old = fm.fileExists(LOG) ? fm.readString(LOG).split("\n").slice(-199) : []
fm.writeString(LOG, [...old, line].join("\n"))
}
/** 今天这一段卡是否已记录 */
async function isDone() {
if (!fm.fileExists(FLAG)) return false
if (!fm.isFileDownloaded(FLAG)) await fm.downloadFileFromiCloud(FLAG)
return fm.readString(FLAG).startsWith(TODAY)
}
/** 记录今天打开钉钉的时间 */
function markDone() {
fm.writeString(FLAG, `${TODAY} ${HM_STR}`)
}
/** 发通知;strong 用刺耳音效并带「已打卡」按钮;delayMin 延后发送 */
function notify(title, body, { strong = false, delayMin = 0 } = {}) {
const n = new Notification()
n.title = title
n.body = body
n.sound = strong ? "failure" : "alert"
n.openURL = "dingtalk://"
n.threadIdentifier = "打卡"
if (strong) n.addAction("✅ 已打卡", DONE_URL)
if (delayMin) n.setTriggerDate(new Date(Date.now() + delayMin * 60000))
return n.schedule()
}
/** 兜底强提醒:立即一次 + 追加几次 */
function strongAlert(body) {
const title = `⚠️ 还没打${KIND}卡!`
return Promise.all([
notify(title, body, { strong: true }),
...CONFIG.repeat.map(m => notify(title, "点开去钉钉", { strong: true, delayMin: m })),
])
}
/** 是否工作日:节假日日历的「班 / 休」优先,否则按周一至周五 */
async function isWorkday() {
try {
const cal = (await Calendar.forEvents()).find(c => c.title === CONFIG.calendar)
if (cal) {
const start = new Date(now); start.setHours(0, 0, 0, 0)
const end = new Date(start); end.setDate(end.getDate() + 1)
for (const ev of await CalendarEvent.between(start, end, [cal])) {
if (ev.title.includes(CONFIG.restKw)) return false
if (ev.title.includes(CONFIG.workKw)) return true
}
} else {
log(`没找到日历「${CONFIG.calendar}」,按周一至周五判断`)
}
} catch (e) {
log(`读日历失败:${e}`)
}
const d = now.getDay()
return d !== 0 && d !== 6
}
/** 两点球面距离(米) */
function distance(a, b) {
const R = 6371000, rad = x => x * Math.PI / 180
const dLat = rad(b.lat - a.latitude), dLng = rad(b.lng - a.longitude)
const h = Math.sin(dLat / 2) ** 2 +
Math.cos(rad(a.latitude)) * Math.cos(rad(b.lat)) * Math.sin(dLng / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(h))
}
/** 让快捷指令打开钉钉 */
const openDingTalk = () => Script.setShortcutOutput("open")
// ---------- 主流程(按开销从低到高检查) ----------
async function main() {
// 0. 手动标记
if (MODE === "done") {
markDone()
log(`手动标记${KIND}卡已打`)
await notify(`✅ ${KIND}卡已记录`, `${TODAY} ${HM_STR}`)
return
}
// 1. 时段(auto)
if (MODE === "auto") {
if (KIND === "上班" && HM >= CONFIG.onBefore) return log("已过上班时段")
if (KIND === "下班" && HM < CONFIG.offAfter) return log("还没到下班时间")
}
// 2. 今天已记录
if (await isDone()) return log(`${KIND}卡今天已记录,跳过`)
// 3. 工作日
if (!(await isWorkday())) return log("非工作日,跳过")
// 4. 兜底:工作日且未记录 → 强提醒并打开钉钉
if (MODE === "check") {
log("兜底检查:未打卡,强提醒")
await strongAlert(KIND === "上班" ? "09:00:59 前要打上班卡,点开去钉钉" : "已过 17:30,别忘了下班卡")
return openDingTalk()
}
// 5. 定位
let dist
try {
Location.setAccuracyToTenMeters()
dist = Math.round(distance(await Location.current(), CONFIG.office))
} catch (e) {
log(`定位失败:${e}`)
await notify("📍 定位失败", `手动去打${KIND}卡`, { strong: true })
return openDingTalk()
}
if (dist > CONFIG.radius) return log(`离公司 ${dist} 米,不在范围`)
// 6. 在范围内:记录并打开钉钉(极速打卡自动完成)
markDone()
log(`离公司 ${dist} 米,打开钉钉打${KIND}卡`)
await notify(`🕘 打${KIND}卡`, `离公司 ${dist} 米,已打开钉钉`)
openDingTalk()
}
await main()
Script.complete()注意:office里的0.000000只是占位,一定要换成你自己工位实测的经纬度,否则脚本永远会判断「不在范围」。
小结
- 打开钉钉交给快捷指令的 Open App,脚本只输出
open。 - 坐标用工位实测 GPS,不要用地图上点的。
- 一条自动化 4 个触发,脚本按时间自己分辨「正常打卡」和「兜底检查」。
- 脚本看不到钉钉里的真实打卡结果,配合极速打卡 + 兜底强提醒,基本可以做到「人到了,卡就打了;忘了,手机会叫」。
暂无评论