标签:Python、Selenium、AppleScript、自动化、Edge、Safari
一、写在前面
有没有想过,早上打开电脑时,它能自动为你打开打卡页面并点击“打卡”按钮?
这篇文章,我将分享自己在 Windows 11 + Edge 浏览器 以及 macOS + Safari 上实现的完整方案。
不需要购买第三方自动化软件,全程只用原生脚本即可搞定!
二、需求背景
我有一个本地网页:上班打卡_单页html交互示例.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>上班打卡模拟</title>
<style>
:root{
--bg:#0f172a; /* slate-900 */
--card:#111827cc; /* slate-900 w/ alpha */
--muted:#94a3b8; /* slate-400 */
--text:#e5e7eb; /* slate-200 */
--primary:#38bdf8; /* sky-400 */
--ok:#22c55e; /* green-500 */
--warn:#f59e0b; /* amber-500 */
--danger:#ef4444; /* red-500 */
--shadow:0 10px 30px rgba(0,0,0,.35);
}
html,body{height:100%}
body{
margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
background: radial-gradient(1200px 600px at 50% -10%, #1e293b, var(--bg));
color:var(--text); display:grid; place-items:center;
}
.wrap{ width:min(920px, 94vw); }
.card{ background:linear-gradient(180deg,#0b1220, #0b1220cc); border:1px solid #1f2937; border-radius:20px; box-shadow:var(--shadow); padding:28px; }
.header{ display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; }
.title{ font-size:clamp(20px, 2vw + 12px, 28px); font-weight:700; letter-spacing:.3px; }
.clock{ font-variant-numeric: tabular-nums; font-size: clamp(28px, 6vw, 48px); font-weight:800; }
.tz { color:var(--muted); font-size:12px; margin-top:4px; }
.main{ display:grid; grid-template-columns: 1fr auto; align-items:center; gap:24px; margin-top:20px; }
@media (max-width: 720px){ .main{ grid-template-columns: 1fr; place-items:center; text-align:center; } }
.status{
display:flex; gap:12px; align-items:center; color:var(--muted);
}
.badge{ padding:4px 10px; border-radius:999px; font-size:12px; border:1px solid #334155; }
.btn-area{ display:grid; place-items:center; }
.punch{
width:220px; height:220px; border-radius:9999px; border:none; cursor:pointer; outline:none;
background: radial-gradient(120px 120px at 35% 30%, #7dd3fc, #0284c7);
color:white; box-shadow: 0 20px 60px rgba(56,189,248,.35), inset 0 -20px 40px rgba(2,132,199,.45);
display:grid; place-items:center; gap:8px; transition: transform .08s ease, filter .2s ease;
position:relative; isolation:isolate;
}
.punch:before{
content:""; position:absolute; inset:-18px; border-radius:inherit; z-index:-1;
background: conic-gradient(from 0deg, rgba(56,189,248,.45), rgba(56,189,248,0) 40%, rgba(56,189,248,.45));
filter: blur(18px); opacity:.65; animation: spin 6s linear infinite;
}
@keyframes spin{ to{ transform: rotate(360deg); } }
.punch:hover{ filter:brightness(1.05); }
.punch:active{ transform: translateY(2px) scale(.995); }
.punch .big{ font-size:28px; font-weight:800; letter-spacing:1px; }
.punch .small{ font-size:12px; opacity:.9 }
.punch.disabled{ opacity:.6; cursor:not-allowed; filter:grayscale(.15); }
.panel{ display:flex; gap:16px; flex-wrap:wrap; margin-top:18px; }
.pill{ border:1px solid #334155; border-radius:14px; padding:8px 12px; color:var(--muted); font-size:13px; }
.tools{ display:flex; gap:10px; flex-wrap:wrap; }
.ghost{ background:transparent; color:var(--muted); border:1px solid #334155; border-radius:10px; padding:8px 12px; cursor:pointer; }
.ghost:hover{ border-color:#475569; color:#cbd5e1 }
.table-wrap{ margin-top:22px; border:1px solid #1f2937; border-radius:16px; overflow:auto; }
table{ width:100%; border-collapse:collapse; min-width: 520px; }
thead{ background:#0b1220cc; position:sticky; top:0; }
th, td{ padding:12px 14px; border-bottom:1px solid #1f2937; font-size:14px; text-align:left; }
tbody tr:hover{ background:#0b122044; }
.tag{ padding:2px 8px; border-radius:999px; font-size:12px; border:1px solid #334155; }
.tag.ok{ color:#bbf7d0; border-color:#14532d; background:#14532d66 }
.tag.warn{ color:#fde68a; border-color:#78350f; background:#78350f66 }
.toast{ position:fixed; inset:auto 0 28px 0; display:grid; place-items:center; pointer-events:none; }
.toast > div{ background:#0b1220cc; border:1px solid #1f2937; color:var(--text); border-radius:12px; padding:10px 14px; box-shadow:var(--shadow); opacity:0; transform: translateY(10px); transition: all .25s ease; }
.toast.show > div{ opacity:1; transform: translateY(0); }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<div class="header">
<div>
<div class="title">上班打卡 · 模拟页面</div>
<div class="tz">时区:Asia/Taipei(可本地离线使用,记录保存在浏览器)</div>
</div>
<div>
<div class="clock" id="clock">--:--:--</div>
</div>
</div>
<div class="main">
<div>
<div class="status">
<span class="badge" id="todayStatus">今天未打卡</span>
<span class="badge" id="lastPunch">上次打卡:—</span>
</div>
<div class="panel">
<span class="pill">上班时间:<strong id="shiftSpan">09:00</strong></span>
<span class="pill">打卡规则:同一自然日仅允许一次“上班打卡”</span>
</div>
<div class="panel tools" style="margin-top:10px">
<button class="ghost" id="changeShiftBtn" aria-label="设置上班时间">设置上班时间</button>
<button class="ghost" id="exportBtn" aria-label="导出CSV">导出 CSV</button>
<button class="ghost" id="resetBtn" aria-label="清空记录">清空记录</button>
</div>
</div>
<div class="btn-area">
<button id="punchBtn" class="punch" aria-label="打卡">
<div class="big">打 卡</div>
<div class="small">Click to Punch In</div>
</button>
</div>
</div>
<div class="table-wrap" aria-live="polite">
<table>
<thead>
<tr>
<th>#</th>
<th>日期</th>
<th>时间</th>
<th>状态</th>
<th>备注</th>
</tr>
</thead>
<tbody id="logBody"></tbody>
</table>
</div>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"><div id="toastMsg">已打卡</div></div>
<script>
// ====== 基础配置 ======
const TZ = 'Asia/Taipei';
const LS_KEY = 'punchLogs_v1';
const SHIFT_KEY = 'shiftTime_v1'; // e.g. '09:00'
// 默认上班时间
const DEFAULT_SHIFT = '09:00';
// ====== 工具函数 ======
const fmt = new Intl.DateTimeFormat('zh-CN', {
timeZone: TZ, year:'numeric', month:'2-digit', day:'2-digit',
hour:'2-digit', minute:'2-digit', second:'2-digit', hour12:false
});
function now(){ return new Date(); }
function formatDate(d){
const dt = new Date(d);
const opt = { timeZone: TZ, year:'numeric', month:'2-digit', day:'2-digit' };
const {year, month, day} = new Intl.DateTimeFormat('zh-CN', opt)
.formatToParts(dt)
.reduce((acc,p)=> (acc[p.type]=p.value, acc),{});
return `${year}-${month}-${day}`;
}
function formatTime(d){
const dt = new Date(d);
const opt = { timeZone: TZ, hour:'2-digit', minute:'2-digit', second:'2-digit', hour12:false };
const {hour, minute, second} = new Intl.DateTimeFormat('zh-CN', opt)
.formatToParts(dt)
.reduce((acc,p)=> (acc[p.type]=p.value, acc),{});
return `${hour}:${minute}:${second}`;
}
function getShift(){ return localStorage.getItem(SHIFT_KEY) || DEFAULT_SHIFT; }
function setShift(v){ localStorage.setItem(SHIFT_KEY, v); }
function loadLogs(){
try{ return JSON.parse(localStorage.getItem(LS_KEY) || '[]'); }
catch{ return []; }
}
function saveLogs(arr){ localStorage.setItem(LS_KEY, JSON.stringify(arr)); }
function isToday(str){ return str === formatDate(now()); }
function compareHHMM(a,b){
// 返回 a 与 b(均为"HH:MM")的时间差(分钟): a-b
const [ah,am] = a.split(':').map(Number);
const [bh,bm] = b.split(':').map(Number);
return (ah*60+am) - (bh*60+bm);
}
function toast(msg){
const box = document.getElementById('toast');
document.getElementById('toastMsg').textContent = msg;
box.classList.add('show');
setTimeout(()=> box.classList.remove('show'), 1600);
}
// ====== 核心逻辑 ======
const clockEl = document.getElementById('clock');
const punchBtn = document.getElementById('punchBtn');
const logBody = document.getElementById('logBody');
const todayStatus = document.getElementById('todayStatus');
const lastPunch = document.getElementById('lastPunch');
const shiftSpan = document.getElementById('shiftSpan');
function tick(){
const d = now();
// 仅显示时分秒(台北时区)
clockEl.textContent = formatTime(d);
}
function refresh(){
const logs = loadLogs();
const today = formatDate(now());
const shift = getShift();
shiftSpan.textContent = shift;
// 渲染表格
logBody.innerHTML = '';
logs.slice().reverse().forEach((row, idx)=>{
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${logs.length - idx}</td>
<td>${row.date}</td>
<td>${row.time}</td>
<td><span class="tag ${row.status==='准时'?'ok':'warn'}">${row.status}</span></td>
<td>${row.note || ''}</td>
`;
logBody.appendChild(tr);
});
// 状态与按钮
const todayLog = logs.find(r=> r.date === today);
if(todayLog){
todayStatus.textContent = `今天已打卡(${todayLog.time} · ${todayLog.status})`;
punchBtn.classList.add('disabled');
punchBtn.disabled = true;
}else{
todayStatus.textContent = '今天未打卡';
punchBtn.classList.remove('disabled');
punchBtn.disabled = false;
}
const last = logs[logs.length-1];
lastPunch.textContent = last ? `上次打卡:${last.date} ${last.time}` : '上次打卡:—';
}
function doPunch(){
const d = now();
const dateStr = formatDate(d);
const timeStr = formatTime(d).slice(0,5); // HH:MM
const shift = getShift();
// 同日仅一次
const logs = loadLogs();
if(logs.some(r => r.date === dateStr)){
toast('今天已打卡');
return;
}
const delta = compareHHMM(timeStr, shift); // 分钟差
const status = delta <= 0 ? '准时' : '迟到';
const note = delta <= 0 ? `提前${Math.abs(delta)}分钟` : `晚到${delta}分钟`;
const row = { date: dateStr, time: timeStr, status, note };
logs.push(row);
saveLogs(logs);
toast('打卡成功');
refresh();
}
// ====== 事件绑定 ======
punchBtn.addEventListener('click', doPunch);
document.getElementById('changeShiftBtn').addEventListener('click', ()=>{
const current = getShift();
const v = prompt('设置上班时间(24小时制 HH:MM)', current);
if(!v) return;
if(!/^([01]\\d|2[0-3]):[0-5]\\d$/.test(v)){
alert('格式不正确,请输入形如 09:00 的时间。');
return;
}
setShift(v);
refresh();
toast('上班时间已更新');
});
document.getElementById('exportBtn').addEventListener('click', ()=>{
const rows = loadLogs();
if(rows.length === 0){ toast('暂无记录'); return; }
const header = ['序号','日期','时间','状态','备注'];
const body = rows.map((r,i)=>[i+1, r.date, r.time, r.status, r.note||'']);
const csv = [header, ...body].map(line => line.map(cell => `"${String(cell).replace(/"/g,'""')}"`).join(',')).join('\n');
const blob = new Blob(["\uFEFF"+csv], {type:'text/csv;charset=utf-8;'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = '打卡记录.csv'; a.click();
URL.revokeObjectURL(url);
});
document.getElementById('resetBtn').addEventListener('click', ()=>{
if(confirm('确定要清空所有打卡记录吗?')){
localStorage.removeItem(LS_KEY);
refresh();
toast('记录已清空');
}
});
// ====== 初始化 ======
if(!localStorage.getItem(SHIFT_KEY)) setShift(DEFAULT_SHIFT);
refresh();
tick();
setInterval(tick, 1000);
</script>
</body>
</html>
其中包含一个 id=“punchBtn” 的按钮,用于模拟“上班打卡”。
本文的目标是让系统在每天开机时或定时自动打开这个模拟网页,并点击“打卡”按钮。
三、Windows 方案:Python + Selenium + Edge
在 Windows 11 上,我们使用 Python + Selenium 控制 Edge 浏览器实现自动化操作。
1️⃣ 安装依赖
pip install -U selenium
pip install webdriver-manager
说明:新版 Selenium 已可自动管理 EdgeDriver,如报错则加装 webdriver-manager。
2️⃣ 完整脚本 auto_punch_edge.py
import time
from pathlib import Path
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
# ---------- 配置区域 ----------
HTML_PATH = Path(r"Z:\Desktop\上班打卡_单页html交互示例.html") # 本地页面路径
TIMEOUT = 15
HEADLESS = False
CLICK_ID = "punchBtn"
# ----------------------------
def build_edge(headless: bool = False):
from selenium.webdriver.edge.options import Options
options = Options()
if headless:
options.add_argument("--headless=new")
options.add_argument("--disable-gpu")
options.add_argument("--window-size=1280,900")
options.add_experimental_option("detach", True) # ⭐关键:脚本结束后浏览器保持打开
try:
driver = webdriver.Edge(options=options)
return driver
except Exception:
from selenium.webdriver.edge.service import Service
from webdriver_manager.microsoft import EdgeChromiumDriverManager
service = Service(EdgeChromiumDriverManager().install())
driver = webdriver.Edge(service=service, options=options)
return driver
def click_punch(driver):
try:
btn = WebDriverWait(driver, 3).until(
EC.element_to_be_clickable((By.ID, CLICK_ID))
)
btn.click()
return True
except Exception:
pass
try:
driver.execute_script("""
(function(){
var el = document.getElementById(arguments[0])
|| Array.from(document.querySelectorAll('button'))
.find(b => (b.innerText||'').includes('打卡'));
if (el) el.click();
})();
""", CLICK_ID)
return True
except Exception:
return False
def main():
if not HTML_PATH.exists():
raise FileNotFoundError(f"找不到文件:{HTML_PATH}")
url = HTML_PATH.resolve().as_uri()
driver = build_edge(HEADLESS)
try:
driver.get(url)
WebDriverWait(driver, TIMEOUT).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
click_punch(driver)
print("✅ 打卡完成,浏览器窗口保持打开。")
input("按回车键退出脚本…") # 阻止程序自动关闭
except Exception as e:
driver.save_screenshot("punch_fail_screenshot.png")
print("❌ 出错:", e)
finally:
pass # 不关闭浏览器
if __name__ == "__main__":
main()
3️⃣ 使用方法
- 双击运行脚本,Edge 会自动打开网页并点击“打卡”按钮;
- 页面加载后不会被关闭;
- 可在任务计划程序中设置每天定时运行:
powershell -ExecutionPolicy Bypass -File "Z:\Desktop\auto_punch_edge.py"
四、macOS 方案:Automator + AppleScript + Safari
macOS 自带的 Automator + AppleScript 是天然的自动化组合。
可以轻松控制 Safari 打开网页并执行 JavaScript。
🔹 AppleScript 完整代码
on run {input, parameters}
-- 1) 用 open -a Safari 打开本地文件(能稳妥处理中文/空格路径)
set thePath to "/Users/jnlk/Desktop/上班打卡_单页html交互示例.html"
do shell script "/usr/bin/open -a Safari " & quoted form of thePath
-- 2) 等 Safari 打开并加载文档
delay 1
tell application "Safari"
-- 尝试轮询 document.readyState,最多等 6 秒
repeat 30 times
try
set readyState to do JavaScript "document.readyState" in document 1
if readyState is "complete" then exit repeat
end try
delay 0.2
end repeat
-- 3) 点击“打卡”按钮(id=punchBtn)
do JavaScript "document.getElementById('punchBtn')?.click();" in document 1
end tell
return input
end run
🔹 使用步骤
- 打开 Automator → 新建「应用程序」;
- 添加「运行 AppleScript」;
- 粘贴上面的代码;
- 保存为 自动打卡.app;
- 双击运行即可。
⚠️ 第一次运行前请到 Safari → 开发菜单中勾选:
允许来自 Apple 事件的 JavaScript
五、功能演示与结果
- ✅ 页面自动打开
- ✅ 自动点击按钮
- ✅ 保留浏览器窗口
- ✅ 可跨平台运行
- ✅ 无第三方依赖(mac 端)
六、后记与扩展思路
这个项目虽然是个“上班打卡”的小工具,但其实是浏览器自动化与本地任务编排的完美入门案例。
你可以把它扩展成:
- 自动登录网页系统;
- 自动抓取日报/考勤数据;
- 自动生成截图并推送微信机器人。
自动化的价值在于:重复动作交给机器,时间留给思考。
📦 资源与下载
- 完整 Python 脚本:见上文代码块
- macOS AppleScript 版本:见上文代码块
- 兼容系统:Windows 10/11、macOS 13+
✍️ 作者寄语
我是一个热爱教育与编程的开发者。
写代码、做项目、自动化生活,是我的日常。
如果你也喜欢这种“用脚本改善工作流”的实践,欢迎留言、收藏、或关注我的 CSDN。

8988

被折叠的 条评论
为什么被折叠?



