跳到正文
VK-Sight
/archives/dingtalk-checkin-shortcuts-scriptable.html

用 iPhone 快捷指令 + Scriptable 做一个钉钉上下班打卡提醒(带兜底)

目录

每天早上最怕的不是起床,而是进了公司忘了打卡。我们公司的考勤规则很硬:上班卡必须在 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 标签页点 +:

  1. 添加 Scriptable → Run Script,Script 选 打卡;点 > 展开,关掉 Run In App 和 Show When Run。
  2. 顶部的 Receive … from … 一行保持默认即可(自动化通过 Run Shortcut 调用时不受它影响)。
  3. 添加 If,变量选上一步的输出 Output,条件选 is,值填 open(没有 is 的话选 has any value 也行,脚本只在需要时才有输出)。
  4. 在 If 和 End If 之间添加 Open App,App 选 DingTalk。Otherwise 留空。

3. 新建一条自动化

切到 Automation 标签页点 +:

  1. 触发选 Time of Day,设 At 8:58 AM,再用 or 加上 5:30 PM、5:34 PM,Repeat 选 Daily。
  2. 再加一个 Arrive 触发,Location 在工位上选 Current Location,半径约 200 m,Time Range 设 8:00–9:00 AM。
  3. 选 Run Immediately,关掉 Notify When Run。
  4. 动作选 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 个触发,脚本按时间自己分辨「正常打卡」和「兜底检查」。
  • 脚本看不到钉钉里的真实打卡结果,配合极速打卡 + 兜底强提醒,基本可以做到「人到了,卡就打了;忘了,手机会叫」。

暂无评论

添加新评论