Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions configs/hpi/activitywatch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""
[[https://activitywatch.net][ActivityWatch]] — window / afk / browser tab tracking.

HPI 本体にこのモジュールは無い (近いのは arbtt と rescuetime だけ)。ActivityWatch は
この環境で一番量のあるローカルデータなので、自前で書いて `my` 名前空間に足す。
`~/.config/my` は HPI が sys.path の先頭に差し込む (my/core/init.py) ので、
implicit namespace package として `my.activitywatch` で読める。

REST API ではなく SQLite を直接読む。API だと aw-server が起動している必要があり、
「過去のデータを後から集計する」という HPI の使い方に合わないため。
"""

from __future__ import annotations

import json
from collections.abc import Iterator, Sequence
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path

from my.core import Json, Paths, Stats, get_files, stat
from my.core.sqlite import sqlite_copy_and_open


def inputs() -> Sequence[Path]:
from my.config import activitywatch as user_config

return get_files(user_config.export_path)


@dataclass
class Event:
dt: datetime
duration: timedelta
bucket: str
hostname: str
type: str
client: str
data: Json

@property
def app(self) -> str | None:
"""currentwindow なら実行中のアプリ名。"""
return self.data.get("app")

@property
def title(self) -> str | None:
"""ウィンドウのタイトル、またはブラウザのページタイトル。"""
return self.data.get("title")

@property
def url(self) -> str | None:
"""web.tab.current のときだけ入る。"""
return self.data.get("url")


def _normalise_hostname(hostname: str) -> str:
"""`MacBook-Mini.local` と `MacBook-Mini` を同じ端末として扱う。

ActivityWatch はバケット ID にホスト名を埋め込むが、ネイティブの watcher と
ブラウザ拡張とで参照する名前が違うことがあり、同じ Mac が2つの端末として
記録される。実際この環境では 2026-08-09 を境に window と afk が `.local` 無しに
切り替わり、ブラウザ側だけ `.local` のまま残った。

DB を書き換えて統合する手もあるが、8ヶ月ぶんの再取得不可能なデータに対して
破壊的な操作をする理由がない。読むときに寄せれば済む。
"""
return hostname.removesuffix(".local")


def _parse_ts(raw: str) -> datetime:
# sqlite には '2026-08-21 02:59:15.142000+00:00' の形で入っている。
dt = datetime.fromisoformat(raw)
if dt.tzinfo is None:
# 念のため。aw-server は UTC で書くので、素の値も UTC とみなす。
dt = dt.replace(tzinfo=timezone.utc)
return dt


def events() -> Iterator[Event]:
"""全バケットのイベントを時系列で返す。"""
for db_path in inputs():
# 稼働中の aw-server が WAL を持っているので、コピーしてから開く。
# immutable=1 で直接開くと WAL 内の新しいイベントを取りこぼす。
with sqlite_copy_and_open(db_path) as conn:
buckets = {
key: (bid, btype, client, _normalise_hostname(hostname))
for key, bid, btype, client, hostname in conn.execute(
"select key, id, type, client, hostname from bucketmodel"
)
}
for bucket_key, ts, duration, datastr in conn.execute(
"select bucket_id, timestamp, duration, datastr"
" from eventmodel order by timestamp"
):
meta = buckets.get(bucket_key)
if meta is None:
# バケットが消えたのにイベントが残っている場合。実データでは
# 見ていないが、外部キーは張られているだけで強制はされない。
continue
bid, btype, client, hostname = meta
yield Event(
dt=_parse_ts(ts),
duration=timedelta(seconds=float(duration)),
bucket=bid,
hostname=hostname,
type=btype,
client=client,
data=json.loads(datastr),
)


def _of_type(wanted: str) -> Iterator[Event]:
for e in events():
if e.type == wanted:
yield e


def window() -> Iterator[Event]:
"""アクティブなウィンドウ。app と title が入る。"""
return _of_type("currentwindow")


def afk() -> Iterator[Event]:
"""離席の有無。data['status'] が 'afk' か 'not-afk'。"""
return _of_type("afkstatus")


def browser() -> Iterator[Event]:
"""ブラウザのタブ。url と title が入る。視聴履歴と検索履歴はここから拾える。"""
return _of_type("web.tab.current")


def stats() -> Stats:
return {
**stat(events),
**stat(window),
**stat(browser),
}
47 changes: 47 additions & 0 deletions configs/hpi/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""
HPI (Human Programming Interface) の設定。`~/.config/my/my/config.py` に置かれる。

HPI は「SaaS から引き出したエクスポートを、ローカルで横断的に引ける形にしておく」
ための枠組み。サービスを離れるときに Takeout なりアーカイブなりを取っておけば、
以後ずっと手元で検索・集計できる。

各モジュールが何を要求するかは `hpi doctor <module>` が教えてくれる。
"""

from pathlib import Path

HOME = Path.home()


class activitywatch:
"""画面・ウィンドウ・ブラウザタブ。自前モジュール (activitywatch.py) が読む。

aw-server が書いている生の SQLite をそのまま指す。エクスポート不要で、
常に最新が読める。
"""

export_path = HOME / "Library/Application Support/activitywatch/aw-server/peewee-sqlite.v2.db"


# --- ここから下は、対応するデータを取ってきたら有効にする ---
#
# Google Takeout。"My Activity" に検索履歴と YouTube の視聴履歴が入っているので、
# Google を離れるときはこれを取っておくと過去ぶんが手元に残る。
# https://takeout.google.com で「マイ アクティビティ」と「YouTube」を選んで zip を落とし、
# 下のパスに置いて class のコメントを外す。
#
# class google:
# takeout_path = HOME / "Documents/exports/takeout/*.zip"
#
# GitHub。ghexport (https://github.com/karlicoss/ghexport) で吐いた JSON を指す。
#
# class github:
# export_path = HOME / "Documents/exports/github/*.json"
#
# ブラウザ履歴。browserexport (https://github.com/purarue/browserexport) を使う。
# ただし ActivityWatch のブラウザ拡張が既にタブ遷移を記録しているので、
# 素の履歴が別途要るかは用途次第。
#
# class browser:
# class export:
# export_path = HOME / "Documents/exports/browser/*.sqlite"
22 changes: 22 additions & 0 deletions nix/home/workstation.nix
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,28 @@ in
url = "https://raw.githubusercontent.com/prh/rules/89a6f9dd057a34dce15698260ced88183e332362/media/WEB%2BDB_PRESS.yml";
hash = "sha256-6RTk8Qs/ZVG71vp7kYhu81CCh3uJwsRYk6ER09DMQVw=";
};
# HPI (Human Programming Interface)。SaaS から引き出したエクスポートを、
# ローカルで横断的に引ける形にしておくための枠組み。
#
# `~/.config/my` が my.config パッケージとして読まれる。HPI 側の
# my/core/init.py が MY_CONFIG (既定は platformdirs の user_config_dir) を
# sys.path の先頭に差し込むので、ここに置いたファイルは implicit namespace
# package として `my.*` で import できる。__init__.py は要らない (PEP 420)。
#
# activitywatch.py は上流に存在しないので自前。ActivityWatch はこの環境で
# 一番量のあるローカルデータ (実測 115 万イベント) なのに、HPI が持っているのは
# arbtt と rescuetime だけだった。
#
# 本体は nix ではなく `uv tool install HPI` で入れる。HPI はモジュールを
# 自分で書き換えて使う前提の設計 (editable install を推奨している) なので、
# store に固めると噛み合わない。~/.local/bin は common.nix で PATH に入って
# いるので、uv が置く `hpi` はそのまま通る。
#
# ライブラリとして使うときは `import my.core.init` を先に呼ぶこと。これが
# sys.path への差し込みを実行する。`hpi` CLI 経由なら不要。
home.file.".config/my/my/config.py".source = ../../configs/hpi/config.py;
home.file.".config/my/my/activitywatch.py".source = ../../configs/hpi/activitywatch.py;

# LaTeX: latexmk default config (LuaLaTeX) and Japanese templates
# latexmk 4.77+ officially supports $XDG_CONFIG_HOME/latexmk/latexmkrc, so use the XDG-compliant location
home.file.".config/latexmk/latexmkrc".source = ../../configs/tex/latexmkrc;
Expand Down
Loading