# 企业微信自动打卡脚本

> 适用平台: **Hamibot** (基于 Auto.js)
>
> 功能: 周一到周六自动上班/下班打卡，支持 Server酱 微信推送通知

---

## 重要提醒

> **使用前请在脚本设置中勾选「启用脚本信息」，否则控制台看不到日志！**

---

## 使用步骤

1. 在 Hamibot 在线编辑器中创建脚本，粘贴下方代码
2. 切换到「配置」标签，粘贴 `config.json` 的内容生成配置表单
3. 在脚本设置中勾选「启用脚本信息」
4. 在「定时任务」中新建定时任务，建议每 3~5 分钟执行一次

---

## 配置项说明

| 参数 | 默认值 | 说明 |
|------|--------|------|
| `clockInStart` | `08:00` | 上班打卡开始时间 |
| `clockInEnd` | `08:30` | 上班打卡结束时间 |
| `clockOutStart` | `18:00` | 下班打卡开始时间 |
| `clockOutEnd` | `18:30` | 下班打卡结束时间 |
| `workDays` | `1,2,3,4,5,6` | 工作日（1=周一 … 6=周六, 0=周日） |
| `serverChanKey` | (空) | Server酱 SendKey，填写后微信接收打卡通知 |
| `randomDelayMin` | `10` | 随机延迟最小值（秒） |
| `randomDelayMax` | `60` | 随机延迟最大值（秒） |
| `stepDelay` | `1000` | 每步操作间隔（毫秒） |
| `unlockPassword` | (空) | 数字锁屏密码，留空仅上滑解锁 |

---

## 执行流程

```
main()
  ├─ 1. 工作日判断  → 周日跳过，周一至周六执行
  ├─ 2. 时间范围判断 → 上班 8:00-8:30 / 下班 18:00-18:30
  ├─ 3. 防重复打卡   → storages 持久化记录当日状态
  ├─ 4. 屏幕解锁     → 上滑解锁（预留密码解锁分支）
  ├─ 5. 启动企业微信  → launchApp + 底部导航栏检测
  ├─ 6. 导航至打卡页面 → text() 控件查找: 工作台 → 打卡
  ├─ 7. 查找按钮     → 四级降级: text → desc → textContains → 兜底
  ├─ 8. 随机延迟     → 10~60 秒随机等待（防检测）
  ├─ 9. 验证打卡结果  → 检测页面反馈文字
  ├─10. 标记打卡状态  → storages 写入
  ├─11. Server酱推送  → 微信接收打卡结果
  └─12. 回桌面退出    → home() + hamibot.exit()
```

---

## 完整脚本代码

```js
/**
 * 企业微信自动打卡脚本
 * 适用平台: Hamibot (基于 Auto.js)
 * 功能: 周一到周六自动上班/下班打卡，支持 Server酱 微信推送通知
 *
 * 【重要】使用前请在脚本设置中勾选「启用脚本信息」，否则控制台看不到日志！
 *
 * 使用步骤:
 * 1. 在 Hamibot 在线编辑器中创建脚本，粘贴本文件内容
 * 2. 切换到「配置」标签，粘贴 config.json 的内容生成配置表单
 * 3. 在脚本设置中勾选「启用脚本信息」
 * 4. 在「定时任务」中新建定时任务，建议每3-5分钟执行一次
 */

// ==================== 等待无障碍服务就绪 ====================
auto.waitFor();

// ==================== 日志系统 ====================
// hamibot.postMessage() 将日志发送到控制台（必须勾选「启用脚本信息」）
// toastLog() 同时在手机上弹窗显示
// 每条日志立即发送，确保脚本中途崩溃时也能看到已输出的日志

function log(msg) {
    var d = new Date();
    var h = d.getHours();
    var m = d.getMinutes();
    var s = d.getSeconds();
    var time = (h < 10 ? '0' + h : h) + ':' + (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s);
    var line = '[' + time + '] ' + msg;
    // 手机上弹窗
    toastLog(line);
    // 立即发送到控制台（每条日志独立发送）
    hamibot.postMessage(line);
}

// ==================== 读取配置 ====================
// 配置项通过 Hamibot 在线编辑器的「配置」标签定义，运行时注入 hamibot.env
var clockInStart  = hamibot.env.clockInStart  || '08:00';
var clockInEnd    = hamibot.env.clockInEnd    || '08:30';
var clockOutStart = hamibot.env.clockOutStart || '18:00';
var clockOutEnd   = hamibot.env.clockOutEnd   || '18:30';
var workDays      = hamibot.env.workDays      || '1,2,3,4,5,6';
var serverChanKey = hamibot.env.serverChanKey || '';
var randomDelayMin = hamibot.env.randomDelayMin || '10';
var randomDelayMax = hamibot.env.randomDelayMax || '60';
var stepDelay     = hamibot.env.stepDelay     || '1000';
var unlockPassword = hamibot.env.unlockPassword || '';

// ==================== 常量 ====================
var PACKAGE_NAME = 'com.tencent.wework';
var STORAGE_NAME = 'wework_checkin';

// ==================== 工具函数 ====================

/** 获取今天日期，格式: YYYY-MM-DD */
function todayStr() {
    var d = new Date();
    var m = d.getMonth() + 1;
    var day = d.getDate();
    return d.getFullYear() + '-' + (m < 10 ? '0' + m : m) + '-' + (day < 10 ? '0' + day : day);
}

/** 获取今天是周几（0=周日, 1=周一, ..., 6=周六） */
function getDayOfWeek() {
    return new Date().getDay();
}

/** 获取当前小时的分钟数（从 00:00 算起） */
function currentMinutes() {
    var now = new Date();
    return now.getHours() * 60 + now.getMinutes();
}

// ==================== 时间判断 ====================

/** 今天是否是需要打卡的工作日 */
function isWorkDay() {
    var today = getDayOfWeek();
    if (today === 0) return false; // 周日永远不打卡
    var parts = workDays.split(',');
    for (var i = 0; i < parts.length; i++) {
        if (parseInt(parts[i].trim()) === today) return true;
    }
    return false;
}

/** 当前时间是否在指定范围内（包含边界） */
function isInTimeRange(startStr, endStr) {
    var mins = currentMinutes();
    var sParts = startStr.split(':');
    var eParts = endStr.split(':');
    var start = parseInt(sParts[0]) * 60 + parseInt(sParts[1]);
    var end   = parseInt(eParts[0]) * 60 + parseInt(eParts[1]);
    return mins >= start && mins <= end;
}

// ==================== 延迟 ====================

/** 随机延迟 */
function randomDelay() {
    var minVal = parseFloat(randomDelayMin);
    var maxVal = parseFloat(randomDelayMax);
    if (isNaN(minVal) || minVal < 0) minVal = 10;
    if (isNaN(maxVal) || maxVal < minVal) maxVal = minVal + 50;
    var sec = Math.random() * (maxVal - minVal) + minVal;
    var ms = Math.round(sec * 1000);
    log('随机延迟 ' + (ms / 1000).toFixed(1) + ' 秒');
    sleep(ms);
}

/** 步骤延迟 */
function stepWait(multiplier) {
    if (!multiplier) multiplier = 1;
    var base = parseInt(stepDelay);
    if (isNaN(base) || base < 300) base = 1000;
    sleep(base * multiplier);
}

// ==================== 打卡状态存储 ====================

function getStorage() {
    return storages.create(STORAGE_NAME);
}

function isCheckedToday(type) {
    var storage = getStorage();
    var record = storage.get(todayStr(), {});
    return record[type] === true;
}

function markChecked(type) {
    var storage = getStorage();
    var record = storage.get(todayStr(), {});
    record[type] = true;
    storage.put(todayStr(), record);
    log('已记录今日' + (type === 'in' ? '上班' : '下班') + '打卡');
}

// ==================== 屏幕解锁 ====================

function unlockScreen() {
    log('检查屏幕状态...');

    // 唤醒屏幕
    if (!device.isScreenOn()) {
        log('屏幕已熄灭，正在点亮');
        device.wakeUp();
        sleep(1500);
    } else {
        log('屏幕已是亮屏状态');
    }

    var pw = (unlockPassword || '').trim();
    var sw = Math.floor(device.width / 2);
    var sh = device.height;

    if (pw) {
        // 数字密码解锁
        log('使用数字密码解锁');
        swipe(sw, Math.floor(sh * 0.85), sw, Math.floor(sh * 0.15), 600);
        sleep(1500);

        for (var i = 0; i < pw.length; i++) {
            var digit = pw.charAt(i);
            var key = text(digit).findOne(2000);
            if (key) {
                key.click();
                sleep(300);
            } else {
                key = desc(digit).findOne(1000);
                if (key) {
                    key.click();
                    sleep(300);
                }
            }
        }
        sleep(1000);
    } else {
        // 上滑解锁
        log('上滑解锁');
        swipe(sw, Math.floor(sh * 0.85), sw, Math.floor(sh * 0.15), 600);
        sleep(1000);
    }

    log('解锁完成');
}

// ==================== 启动企业微信 ====================

function openWeChatWork() {
    log('启动企业微信...');

    launchApp('企业微信');
    stepWait(3);

    // 等待底部导航栏出现
    var keywords = ['消息', '工作台', '通讯录', '我'];
    for (var i = 0; i < 15; i++) {
        for (var j = 0; j < keywords.length; j++) {
            if (text(keywords[j]).exists()) {
                log('企业微信已启动');
                return true;
            }
        }
        sleep(1000);
    }

    // 重试：通过包名启动
    log('首次启动超时，通过包名重试...');
    launch(PACKAGE_NAME);
    stepWait(3);

    for (var k = 0; k < 10; k++) {
        for (var m = 0; m < keywords.length; m++) {
            if (text(keywords[m]).exists()) {
                log('企业微信已启动（二次启动成功）');
                return true;
            }
        }
        sleep(1000);
    }

    throw new Error('无法启动企业微信');
}

// ==================== 导航到打卡页面 ====================

function navigateToCheckIn() {
    log('导航: 首页 → 工作台 → 打卡');

    // 第1步：点击「工作台」
    var workbench = text('工作台').findOne(5000);
    if (!workbench) {
        workbench = desc('工作台').findOne(2000);
    }
    if (!workbench) {
        throw new Error('找不到「工作台」标签');
    }
    workbench.click();
    stepWait(2);
    log('  已进入工作台');

    // 第2步：找「打卡」入口
    var appNames = ['打卡', '考勤', '考勤打卡'];
    var checkInApp = null;

    for (var i = 0; i < appNames.length; i++) {
        checkInApp = text(appNames[i]).findOne(2000);
        if (checkInApp) break;
    }
    if (!checkInApp) {
        for (var j = 0; j < appNames.length; j++) {
            checkInApp = desc(appNames[j]).findOne(1000);
            if (checkInApp) break;
        }
    }
    if (!checkInApp) {
        log('  首屏未找到，向下滑动查找');
        swipe(Math.floor(device.width / 2), Math.floor(device.height * 0.7),
              Math.floor(device.width / 2), Math.floor(device.height * 0.3), 400);
        stepWait(1);
        for (var k = 0; k < appNames.length; k++) {
            checkInApp = text(appNames[k]).findOne(2000);
            if (checkInApp) break;
        }
    }
    if (!checkInApp) {
        throw new Error('找不到「打卡」/「考勤」入口');
    }

    log('  找到: "' + (checkInApp.text() || checkInApp.desc() || '打卡') + '"');
    checkInApp.click();
    stepWait(2);
    log('  已进入打卡页面');
}

// ==================== 执行打卡 ====================

function performCheckIn(type) {
    var label = type === 'in' ? '上班' : '下班';
    log('准备' + label + '打卡...');

    // 第1步：检查是否已打过卡
    var alreadyIndicators = ['已打卡', '打卡成功', '正常', '外勤打卡'];
    for (var i = 0; i < alreadyIndicators.length; i++) {
        if (textContains(alreadyIndicators[i]).exists()) {
            log('页面已显示打卡记录，无需重复打卡');
            return true;
        }
    }

    // 第2步：查找打卡按钮
    var buttonTexts = type === 'in'
        ? ['上班打卡', '签到', '打卡上班', '上班']
        : ['下班打卡', '签退', '打卡下班', '下班'];

    var btn = null;
    var matchedText = '';

    // text 精确匹配
    for (var j = 0; j < buttonTexts.length; j++) {
        btn = text(buttonTexts[j]).findOne(1500);
        if (btn) { matchedText = buttonTexts[j]; break; }
    }
    // desc 匹配
    if (!btn) {
        for (var k = 0; k < buttonTexts.length; k++) {
            btn = desc(buttonTexts[k]).findOne(1000);
            if (btn) { matchedText = buttonTexts[k]; break; }
        }
    }
    // textContains 模糊匹配
    if (!btn) {
        for (var m = 0; m < buttonTexts.length; m++) {
            btn = textContains(buttonTexts[m]).findOne(1000);
            if (btn) { matchedText = buttonTexts[m]; break; }
        }
    }
    // 兜底：可点击的「打卡」
    if (!btn) {
        btn = text('打卡').clickable().findOne(1500);
        if (btn) matchedText = '打卡';
    }

    if (!btn) {
        throw new Error('找不到' + label + '打卡按钮');
    }

    log('  按钮: "' + matchedText + '"');

    // 第3步：随机延迟 + 点击
    randomDelay();
    btn.click();
    log('  已点击' + label + '打卡按钮');
    stepWait(2);

    // 第4步：验证结果
    var successIndicators = ['打卡成功', '已打卡', '正常', '外勤'];
    for (var n = 0; n < successIndicators.length; n++) {
        if (textContains(successIndicators[n]).exists()) {
            log(label + '打卡成功！');
            return true;
        }
    }

    // 按钮消失 = 可能成功
    if (!text(matchedText).exists()) {
        log(label + '打卡按钮已消失，视为成功');
        return true;
    }

    log(label + '打卡操作已执行');
    return true;
}

// ==================== Server酱 消息推送 ====================

function sendNotification(type, success) {
    var key = (serverChanKey || '').trim();
    if (!key) return;

    var typeLabel = type === 'in' ? '上班' : '下班';
    var statusLabel = success ? '成功' : '失败';
    var weekDays = ['日', '一', '二', '三', '四', '五', '六'];

    var title = '企业微信' + typeLabel + '打卡' + statusLabel;
    var content = '## ' + title + '\n\n' +
        '- 日期: ' + todayStr() + '\n' +
        '- 类型: ' + typeLabel + '打卡\n' +
        '- 结果: ' + statusLabel + '\n' +
        '- 星期: ' + weekDays[getDayOfWeek()] + '\n';

    var url = 'https://sctapi.ftqq.com/' + key + '.send';

    try {
        var res = http.post(url, { title: title, desp: content });
        if (res.statusCode === 200) {
            log('Server酱通知已发送');
        } else {
            log('Server酱返回状态码: ' + res.statusCode);
        }
    } catch (e) {
        log('Server酱推送失败: ' + e.message);
    }
}

// ==================== 主流程 ====================

function main() {
    log('========== 企业微信打卡脚本启动 ==========');

    // 打印配置摘要，方便确认配置是否正确
    log('上班时间: ' + clockInStart + '-' + clockInEnd);
    log('下班时间: ' + clockOutStart + '-' + clockOutEnd);
    log('工作日: ' + workDays);
    log('Server酱: ' + (serverChanKey ? '已配置' : '未配置'));

    var weekDays = ['日', '一', '二', '三', '四', '五', '六'];
    log('当前: ' + todayStr() + ' 周' + weekDays[getDayOfWeek()]);

    var checkInType = null;

    try {
        // ---- 1. 工作日判断 ----
        if (!isWorkDay()) {
            log('今天不是工作日，跳过');
            return;
        }
        log('✓ 是工作日');

        // ---- 2. 时间范围判断 ----
        var canIn  = isInTimeRange(clockInStart, clockInEnd);
        var canOut = isInTimeRange(clockOutStart, clockOutEnd);

        log('上班时段(' + clockInStart + '-' + clockInEnd + '): ' + (canIn ? '范围内' : '范围外'));
        log('下班时段(' + clockOutStart + '-' + clockOutEnd + '): ' + (canOut ? '范围内' : '范围外'));

        if (!canIn && !canOut) {
            log('当前不在打卡时段内，退出');
            return;
        }

        // ---- 3. 防重复打卡 ----
        var doneIn  = isCheckedToday('in');
        var doneOut = isCheckedToday('out');
        log('今日记录: 上班' + (doneIn ? '已打' : '未打') + ', 下班' + (doneOut ? '已打' : '未打'));

        if (canIn && !doneIn) {
            checkInType = 'in';
        } else if (canOut && !doneOut) {
            checkInType = 'out';
        } else {
            if (canIn && doneIn) log('上班已打过卡');
            if (canOut && doneOut) log('下班已打过卡');
            return;
        }

        log('→ 将执行: ' + (checkInType === 'in' ? '上班打卡' : '下班打卡'));

        // ---- 4. 解锁 ----
        unlockScreen();

        // ---- 5. 启动企业微信 ----
        openWeChatWork();

        // ---- 6. 导航到打卡页 ----
        navigateToCheckIn();

        // ---- 7. 打卡 ----
        var success = performCheckIn(checkInType);

        // ---- 8. 记录 ----
        if (success) markChecked(checkInType);

        // ---- 9. 通知 ----
        sendNotification(checkInType, success);

        log('任务完成: ' + (checkInType === 'in' ? '上班' : '下班') + '打卡' + (success ? '成功' : '完成'));

    } catch (e) {
        log('【异常】' + e.message);
        if (checkInType) sendNotification(checkInType, false);
    } finally {
        // ---- 10. 收尾 ----
        sleep(2000);
        home();
        log('========== 脚本结束 ==========');
        // hamibot.exit() 会等待 postMessage 全部发送完成后再退出
        hamibot.exit();
    }
}

// ==================== 启动 ====================
main();
```

---

## 依赖的 Hamibot API

| API | 用途 |
|-----|------|
| `auto.waitFor()` | 等待无障碍服务就绪 |
| `hamibot.postMessage(text)` | 发送日志到控制台 |
| `hamibot.exit()` | 安全退出（等待消息发送完成） |
| `hamibot.env.*` | 读取用户配置 |
| `toastLog(msg)` | 手机弹窗显示日志 |
| `launchApp(name)` | 按名称启动应用 |
| `launch(packageName)` | 按包名启动应用 |
| `text(str).findOne()` | 按文本查找控件 |
| `desc(str).findOne()` | 按描述查找控件 |
| `textContains(str).exists()` | 模糊匹配控件是否存在 |
| `UiObject.click()` | 点击控件 |
| `storages.create(name)` | 创建持久化存储 |
| `device.isScreenOn()` | 检测屏幕亮灭 |
| `device.wakeUp()` | 点亮屏幕 |
| `swipe(x1,y1,x2,y2,dur)` | 模拟滑动 |
| `sleep(ms)` | 等待指定毫秒 |
| `home()` | 返回桌面 |
| `http.post(url, data)` | HTTP 请求 |
