Skip to content

Instantly share code, notes, and snippets.

@deveworld
Created June 3, 2026 12:57
Show Gist options
  • Select an option

  • Save deveworld/5e0a149229fde243f199e79afb07dd28 to your computer and use it in GitHub Desktop.

Select an option

Save deveworld/5e0a149229fde243f199e79afb07dd28 to your computer and use it in GitHub Desktop.
hackatime_timer
#!/usr/bin/env python3
"""
hackatime_timer.py — Hackatime 수동 타이머 (manual timer)
에디터 플러그인 없이, 손으로 시작/정지하는 타이머로 Hackatime에 코딩 시간을
기록합니다. 타이머가 도는 동안 일정 간격으로 heartbeat를 보내며, 정지 시
마지막 heartbeat를 보내 "시작~정지"까지의 실제 경과 시간이 기록되도록 합니다.
원리 (hackclub/hackatime 소스 기준):
- 서버는 heartbeat 사이 간격이 120초(2분) 이내이면 하나의 연속 구간(span)으로
묶고, 그 구간의 길이 = (마지막 heartbeat 시각 - 첫 heartbeat 시각) 입니다.
- 따라서 120초보다 짧은 간격으로 heartbeat를 보내면 실시간으로 시간이 쌓입니다.
표준 라이브러리만 사용합니다 (pip 설치 불필요).
"""
import argparse
import configparser
import json
import os
import platform
import signal
import sys
import time
import urllib.error
import urllib.request
DEFAULT_API_URL = "https://hackatime.hackclub.com/api/hackatime/v1"
WAKATIME_CFG = os.path.expanduser("~/.wakatime.cfg")
TIMEOUT_SECONDS = 120 # 서버의 heartbeat timeout (2분). 간격은 이보다 작아야 함.
VERSION = "1.0"
# ──────────────────────────────────────────────────────────────────────────
# 설정 해석 (API 키 / URL)
# ──────────────────────────────────────────────────────────────────────────
def read_wakatime_cfg():
"""~/.wakatime.cfg 의 [settings] 에서 api_key / api_url 을 읽는다."""
cfg = {"api_key": None, "api_url": None}
if not os.path.exists(WAKATIME_CFG):
return cfg
parser = configparser.ConfigParser()
try:
parser.read(WAKATIME_CFG)
if parser.has_section("settings"):
cfg["api_key"] = parser.get("settings", "api_key", fallback=None)
cfg["api_url"] = parser.get("settings", "api_url", fallback=None)
except (configparser.Error, OSError):
pass
return cfg
def resolve_config(args):
file_cfg = read_wakatime_cfg()
api_key = (
args.api_key
or os.environ.get("HACKATIME_API_KEY")
or os.environ.get("WAKATIME_API_KEY")
or file_cfg["api_key"]
)
api_url = (
args.api_url
or os.environ.get("HACKATIME_API_URL")
or file_cfg["api_url"]
or DEFAULT_API_URL
)
api_url = api_url.rstrip("/")
return api_key, api_url
def default_editor():
return "hackatime-manual-timer"
def default_os():
return platform.system().lower() or "unknown"
def build_user_agent(editor=None, os_name=None):
"""서버의 UA 정규식
wakatime/<v> (<os>-<arch>) <runtime> <editor>/<ver>
이 editor / operating_system 을 추출하므로, 원하는 값을 여기에 심는다.
서버는 push 시 body 의 editor / operating_system 필드를 무시하고
user_agent 에서 파싱한 값으로 덮어쓴다. 따라서 Editor/OS 를 수동 지정하려면
반드시 user_agent 를 통해야 한다.
"""
arch = platform.machine() or "x86_64"
# OS 는 파싱 시 "-" 로 split 되므로 "-" 를 "_" 로 치환해 잘림을 방지
os_clean = (os_name or default_os()).replace("-", "_").strip() or "unknown"
# editor 는 "/" 직전까지 캡처되므로 "/" 만 치환
editor_clean = (editor or default_editor()).replace("/", "-").strip() or "editor"
return f"wakatime/1.0.0 ({os_clean}-{arch}) python {editor_clean}/{VERSION}"
# ──────────────────────────────────────────────────────────────────────────
# heartbeat 전송
# ──────────────────────────────────────────────────────────────────────────
def send_heartbeat(api_url, api_key, *, entity, project, language, category="coding",
editor=None, os_name=None, machine=None, ts=None, is_write=False):
"""heartbeat 1개를 전송. (성공여부, 메시지) 반환."""
if ts is None:
ts = time.time()
ua = build_user_agent(editor, os_name)
payload = [{
"type": "file",
"time": ts,
"entity": entity,
"project": project,
"language": language,
"category": category,
"is_write": is_write,
# 아래 editor/operating_system 은 서버가 user_agent 로 덮어쓰지만,
# 호환성을 위해 함께 보낸다.
"editor": editor or default_editor(),
"operating_system": os_name or default_os(),
"user_agent": ua,
}]
data = json.dumps(payload).encode("utf-8")
url = f"{api_url}/users/current/heartbeats"
req = urllib.request.Request(url, data=data, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", f"Bearer {api_key}")
req.add_header("User-Agent", ua)
if machine:
# machine 은 body 가 아니라 X-Machine-Name 헤더에서 읽힌다.
req.add_header("X-Machine-Name", machine)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return True, f"{resp.status}"
except urllib.error.HTTPError as e:
body = ""
try:
body = e.read().decode("utf-8", "replace")[:300]
except Exception:
pass
return False, f"HTTP {e.code} {e.reason} {body}".strip()
except urllib.error.URLError as e:
return False, f"네트워크 오류: {e.reason}"
except Exception as e: # noqa: BLE001
return False, f"오류: {e}"
# ──────────────────────────────────────────────────────────────────────────
# 화면 표시
# ──────────────────────────────────────────────────────────────────────────
def fmt_hms(seconds):
seconds = int(seconds)
h, rem = divmod(seconds, 3600)
m, s = divmod(rem, 60)
return f"{h:02d}:{m:02d}:{s:02d}"
def status_line(elapsed, last_ok, sent):
mark = "✓" if last_ok else "✗"
sys.stdout.write(
f"\r⏱ 경과 {fmt_hms(elapsed)} | heartbeat {sent}개 보냄 {mark} "
)
sys.stdout.flush()
# ──────────────────────────────────────────────────────────────────────────
# 메인 타이머 루프
# ──────────────────────────────────────────────────────────────────────────
def run_timer(args, api_url, api_key):
interval = args.interval
if interval >= TIMEOUT_SECONDS:
print(f"⚠ 간격({interval}s)이 서버 timeout({TIMEOUT_SECONDS}s) 이상이라 "
f"시간이 끊겨 기록됩니다. {TIMEOUT_SECONDS}s 미만으로 낮추세요.",
file=sys.stderr)
hb_kwargs = dict(
entity=args.entity,
project=args.project,
language=args.language,
category=args.category,
editor=args.editor,
os_name=args.os,
machine=args.machine,
)
print(f"▶ Hackatime 수동 타이머 시작")
print(f" 프로젝트 : {args.project}")
print(f" 파일/엔티티: {args.entity} 언어: {args.language}")
print(f" 에디터 : {args.editor or default_editor()} OS: {args.os or default_os()}"
+ (f" 머신: {args.machine}" if args.machine else ""))
print(f" 서버 : {api_url}")
print(f" heartbeat : {interval}초 마다 전송")
if args.minutes:
print(f" 자동 정지 : {args.minutes}분 후")
print(f" 정지하려면 Ctrl+C 를 누르세요.\n")
start = time.monotonic()
sent = 0
stop = {"flag": False}
def handle_stop(signum, frame): # noqa: ARG001
stop["flag"] = True
signal.signal(signal.SIGINT, handle_stop)
signal.signal(signal.SIGTERM, handle_stop)
# 시작 heartbeat
ok, msg = send_heartbeat(api_url, api_key, **hb_kwargs)
sent += 1
if not ok:
print(f"\n✗ 첫 heartbeat 전송 실패: {msg}", file=sys.stderr)
if "401" in msg or "Unauthorized" in msg:
print(" → API 키를 확인하세요. (--api-key / $HACKATIME_API_KEY / ~/.wakatime.cfg)",
file=sys.stderr)
return 1
last_send = time.monotonic()
last_ok = ok
try:
while not stop["flag"]:
now = time.monotonic()
elapsed = now - start
if args.minutes and elapsed >= args.minutes * 60:
break
if now - last_send >= interval:
ok, msg = send_heartbeat(api_url, api_key, **hb_kwargs)
sent += 1
last_ok = ok
last_send = now
if not ok:
sys.stdout.write("\n")
print(f"⚠ heartbeat 전송 실패: {msg}", file=sys.stderr)
status_line(elapsed, last_ok, sent)
time.sleep(0.5)
finally:
# 정지 시점 최종 heartbeat → 마지막 시각이 정확히 기록되도록
final_elapsed = time.monotonic() - start
ok, msg = send_heartbeat(api_url, api_key, is_write=True, **hb_kwargs)
sent += 1
status_line(final_elapsed, ok, sent)
print() # 줄바꿈
print(f"\n■ 정지. 이번 세션 기록 시간 ≈ {fmt_hms(final_elapsed)} "
f"({final_elapsed/60:.1f}분), heartbeat {sent}개 전송")
if not ok:
print(f" ⚠ 마지막 heartbeat 실패: {msg}", file=sys.stderr)
print(" 대시보드에서 확인: https://hackatime.hackclub.com/")
return 0
# ──────────────────────────────────────────────────────────────────────────
# 진입점
# ──────────────────────────────────────────────────────────────────────────
def main(argv=None):
p = argparse.ArgumentParser(
prog="hackatime_timer.py",
description="Hackatime 수동 타이머 — 시작/정지로 코딩 시간을 직접 기록",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""예시:
# 'my-project' 작업 시간을 측정 (Ctrl+C 로 정지)
python3 hackatime_timer.py my-project
# 프로젝트/언어/에디터/OS 수동 지정, 90초 간격, 25분 후 자동 정지
python3 hackatime_timer.py my-project --entity main.py --language Python \\
--editor vscode --os macos --machine my-laptop \\
--interval 90 --minutes 25
# 연결/키 테스트만 (test heartbeat 1개 전송, 실제 시간 미반영)
python3 hackatime_timer.py --test
API 키 우선순위: --api-key > $HACKATIME_API_KEY > ~/.wakatime.cfg
""",
)
p.add_argument("project", nargs="?", default="manual-timer",
help="프로젝트 이름 (대시보드에 표시됨). 기본: manual-timer")
p.add_argument("--entity", default="manual.txt",
help="파일/엔티티 이름. 기본: manual.txt (test.txt 는 테스트로 처리되니 사용 금지)")
p.add_argument("--language", default="Other", help="언어 이름. 기본: Other (예: Python, JavaScript, Ruby)")
p.add_argument("--editor", default=None,
help="에디터 이름. 미지정 시 hackatime-manual-timer. "
"(예: vscode, pycharm, intellij, vim, neovim, sublime text, claude code)")
p.add_argument("--os", dest="os", default=None,
help="운영체제 이름. 미지정 시 현재 OS 자동 감지. "
"(예: macos, linux, windows, wsl)")
p.add_argument("--machine", default=None,
help="머신 이름(X-Machine-Name 헤더로 전송). 미지정 시 서버가 비워둠")
p.add_argument("--category", default="coding", help="카테고리. 기본: coding")
p.add_argument("--interval", type=int, default=60,
help=f"heartbeat 전송 간격(초). 기본 60, {TIMEOUT_SECONDS} 미만 권장")
p.add_argument("--minutes", type=float, default=None,
help="지정 분 후 자동 정지. 미지정 시 Ctrl+C 까지 계속")
p.add_argument("--api-key", default=None, help="Hackatime API 키")
p.add_argument("--api-url", default=None, help=f"API URL. 기본: {DEFAULT_API_URL}")
p.add_argument("--test", action="store_true",
help="test heartbeat 1개만 보내 연결/키를 확인 (실제 시간 미반영)")
args = p.parse_args(argv)
api_key, api_url = resolve_config(args)
if not api_key:
print("✗ API 키가 없습니다. 다음 중 하나로 지정하세요:", file=sys.stderr)
print(" --api-key <KEY>", file=sys.stderr)
print(" export HACKATIME_API_KEY=<KEY>", file=sys.stderr)
print(" ~/.wakatime.cfg 의 [settings] api_key", file=sys.stderr)
print("키 발급: https://hackatime.hackclub.com/ (로그인 후 My Account)", file=sys.stderr)
return 2
if args.test:
print(f"테스트 heartbeat 전송 → {api_url}")
ok, msg = send_heartbeat(api_url, api_key, entity="test.txt",
project=args.project, language=args.language,
editor=args.editor, os_name=args.os, machine=args.machine)
if ok:
print(f"✓ 성공 (HTTP {msg}). 키와 연결이 정상입니다.")
return 0
print(f"✗ 실패: {msg}", file=sys.stderr)
return 1
return run_timer(args, api_url, api_key)
if __name__ == "__main__":
sys.exit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment