diff --git a/configs/hpi/activitywatch.py b/configs/hpi/activitywatch.py new file mode 100644 index 00000000..586f27e8 --- /dev/null +++ b/configs/hpi/activitywatch.py @@ -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), + } diff --git a/configs/hpi/config.py b/configs/hpi/config.py new file mode 100644 index 00000000..bbb24d6a --- /dev/null +++ b/configs/hpi/config.py @@ -0,0 +1,47 @@ +""" +HPI (Human Programming Interface) の設定。`~/.config/my/my/config.py` に置かれる。 + +HPI は「SaaS から引き出したエクスポートを、ローカルで横断的に引ける形にしておく」 +ための枠組み。サービスを離れるときに Takeout なりアーカイブなりを取っておけば、 +以後ずっと手元で検索・集計できる。 + +各モジュールが何を要求するかは `hpi doctor ` が教えてくれる。 +""" + +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" diff --git a/nix/home/workstation.nix b/nix/home/workstation.nix index ddd9fcdc..1b8f16c5 100644 --- a/nix/home/workstation.nix +++ b/nix/home/workstation.nix @@ -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;