# @yuiseki/hatebucli Your Hatena Bookmark history, on your machine and answerable. Hatena Bookmark gives you a feed of what you bookmarked, one day at a time, and not much else. `hatebu` syncs those days into a local cache and then answers questions over the whole of it: what you were reading in 2019, which sites you keep coming back to, when a subject first showed up, whether you have already bookmarked the page in front of you. Everything except today is answered from the cache, so it works offline and it stays fast on an archive of hundreds of thousands of bookmarks. It also runs as an [MCP server](#mcp-server), so an assistant can ask the same questions without you translating each one into flags. ## Install Not on npm yet. Install it from a checkout: ```bash git clone https://github.com/yuiseki/hatebucli.git cd hatebucli npm install && npm run build npm link # puts `hatebu` on your PATH ``` Needs Node 22.23.4, 24.22.1, or 26.12.1 or newer within those release lines. The security fixes in those releases cover the supported Node.js lines. Run it on an older one and it says so rather than failing from inside `node_modules`. ## Getting started ```bash hatebu config set username hatebu sync --days 30 hatebu words ``` `sync` is what fills the cache, one request per day, spaced out because the feed is somebody else's server. Nothing else here fetches anything, apart from today. ## Commands Every command that reports bookmarks or rankings takes `-j, --json`. ### One day ```bash hatebu list # today, fresh from the feed hatebu list --date 2026-02-18 # an earlier day, from the cache ``` | Option | | | --- | --- | | `-d, --date ` | the day. Defaults to today | | `-j, --json` | | ### Search ```bash hatebu search 地図 # every cached day, best match first hatebu search zenn.dev --field url hatebu search 地図 --date 2026-02-18 -l 50 ``` A query is one or more terms separated by whitespace, and every term has to appear. A term of plain letters and digits has to appear as a whole word, so `ai` means AI and not `mai`, `aichi` or `maplibre`. Anything else is matched as written: `地図` finds `地図帳` and `白地図`, and `arxiv.org` is a host. `地図 AI` is two terms and needs both. Results are ordered by score, then newest first, and the last line says how many matched when the limit cut them. `--match substring` looks inside words, which is what finds `LiteLLM` when you search `llm`. See [ADR 012](docs/ADR/012-word-terms.md) for the measurements behind the default. To count what came from a site, use `hatebu lookup` instead: it matches the hostname, where a URL search matches the text of the URL anywhere in it. | Option | | | --- | --- | | `-f, --field ` | where to match. Default `all` | | `-m, --match ` | how a plain term is matched. Default `word` | | `-d, --date ` | one day only | | `-l, --limit ` | default 10 | | `-j, --json` | | ### Rankings over a range ```bash hatebu domains --date 2026 # the sites of a year hatebu tags --date 2026-02 # the tags of a month hatebu words # what last week was about hatebu words --today hatebu stats --days 30 --top 20 # a window as one summary ``` `domains`, `tags` and `words` take the same range options. With none of them the range is the week ending yesterday. | Option | | | --- | --- | | `--date ` | a day, a month or a year | | `--today` | today only. Cannot be combined with `--date` | | `-l, --limit ` | rows. Default 10, capped at 10 for `domains` and `tags`, 30 for `words` | | `--by ` | `words` only. Default `distinctive` | | `-j, --json` | | `words` runs the titles through a Japanese morphological analyser and ranks by how much more the range used each word than the archive usually does, so the answer is what the stretch was about rather than which words it contained. A word counts once per bookmark however often the title repeats it. ```bash hatebu words --rebuild-background # once, about a minute hatebu words --date 2013 ``` The background is one file of word counts over the whole archive, in total and by year, about 4MB. Building it tokenizes every title, which takes a minute, so it is a command you run rather than something that happens during `sync`; a day of new bookmarks moves a background of 374,000 by nothing. Without it, `words` falls back to raw counts and says so. `--by count` asks for those on purpose. Ranking this way is what makes a year legible: ``` 2013 js node grunt javascript fluentd css backbone sass angularjs chef 2019 nhk ニュース 朝日新聞 ライブドアニュース 放火 yahoo pay 香港 文春 ``` Frequency alone answers `ai github for and the https` for every year of this archive. See [ADR 010](docs/ADR/010-distinctive-words.md). `stats` puts a window together: how much, at what hours, on what weekdays, and the sites and tags that led it. Markdown to read, `--json` to compute from; the two are cut the same way, so a bucket nobody bookmarked in is absent from both rather than reported as a zero. | Option | | | --- | --- | | `--date ` | the day the window ends on. Defaults to yesterday | | `--days ` | window length. Default 7 | | `--top ` | rows per section. Default 10, capped at 20 | | `-j, --json` | | Each of these reports the days of the range the cache does not hold, so a low count can be told apart from a gap in the sync. ### The whole archive ```bash hatebu lookup https://example.com/article # have I bookmarked this page? hatebu lookup example.com # what have I read from this site? hatebu timeline --query maplibre --bars # when was I reading about this? hatebu timeline --by month --tag 地図 hatebu tagged 地図 # what did I file under this tag? hatebu random -n 5 --from 2010-01-01 # dig something out of the archive ``` `lookup` answers about one page when given a full URL, and about a site when given a bare hostname. A subdomain counts as part of the site, and the scheme and a trailing slash are ignored, because neither makes it a different page. A URL that was never bookmarked still reports how much came from the same site. `timeline` counts per `year` or per `month`, oldest first, and takes `--tag`, `--domain`, `--query`, `--from` and `--to` in any combination. `--bars` draws each row. `random` takes the same filters. These read every cached day rather than an index. Over an archive of a few hundred thousand bookmarks that is under a second. ### Choosing a best of the day ```bash hatebu pick # yesterday's bookmarks, numbered hatebu pick 12 # the entry page for number 12 hatebu pick --weekly # the week's daily_best hatebu pick --monthly --date 2026-09 ``` `pick` writes nothing to Hatena. It lays out the candidates and gives you the entry page for the one you chose; you add the `daily_best` tag there, the way you would add any tag. The tag comes back into the cache on the next sync, which is what makes the weekly round able to read what the daily round chose: | round | candidates | tag to add | | --- | --- | --- | | `pick` | the day's bookmarks | `daily_best` | | `pick --weekly` | `daily_best` of the last seven days | `weekly_best` | | `pick --monthly` | `weekly_best` of the month | `monthly_best` | `pick` fetches the days of its window first, unless the cache already shows the round as decided, because a tag added on the entry page is invisible until that day is fetched again. A decided round costs no requests; an undecided week costs its seven days. `--no-sync` reads the cache as it stands. A round that is already decided shows what was chosen and stops, rather than asking you to read the day again. A day may have more than one best; they are all shown, and all of them go forward as candidates for the weekly round. `--all` lists the candidates anyway, with the chosen one marked, and a number still resolves against that list if you change your mind. See [ADR 011](docs/ADR/011-best-of-rounds.md) for why this does not go through the write API. ### Reading the archive against itself ```bash hatebu compare 2023 2026 # what rose, and what fell away hatebu compare 2025 2026 --by domains hatebu context https://example.com/article # what it was read alongside hatebu timeline --query maplibre --bars # count and share, per period hatebu bursts --query maplibre # when it flared, and how far above usual hatebu resurface --peaked-after 2015 # what you cared about and stopped mentioning ``` `compare` scores each period against the other, so a word common to both says nothing and a word rare in both can be the whole of the difference. Every row carries both counts and both rates per thousand, because two periods are rarely the same size. ``` More in 2026 claude: 18 → 260 (0.2 → 3.8 per 1000) エージェント: 15 → 155 (0.2 → 2.3 per 1000) More in 2023 chatgpt: 510 → 62 (5.9 → 0.9 per 1000) langchain: 109 → 5 (1.3 → 0.1 per 1000) ``` `bursts` names the episodes a timeline only draws. A month counts when it runs at three times the subject's own rate over the whole archive and holds at least three matches, so a subject is bursting because it is unusual for itself, not because the month was busy: ``` maplibre: 123 bookmarks, usually 0.3 per 1000 2022-11..2023-01 15 bookmarks, peak 2023-01 at 5.6 per 1000 (17x) 2024-10..2024-12 17 bookmarks, peak 2024-11 at 7.4 per 1000 (22.5x) 2026-05..2026-09 28 bookmarks, peak 2026-08 at 12.2 per 1000 (37.1x) ``` `resurface` takes no subject at all: it reads the vocabulary year by year for the shape of something that burned and went out, scoring how loud it got, how gone it is, how long it has been dormant and whether there was ever enough of it. It needs the background model that `words` uses, rebuilt with the version that carries a breakdown by year. See [ADR 013](docs/ADR/013-resurface.md). ``` word peak year then now dormant ニュース 2020 125.22 30.78 6y (17259 bookmarks) android 2015 56.42 4.92 11y (4411 bookmarks) コロナ 2020 77.82 0.94 6y (1687 bookmarks) vr 2018 42.69 0.39 8y (1090 bookmarks) ``` `context` answers why a page was bookmarked, by showing what was bookmarked around it, grouped into sittings by the quiet between them. Give it a URL or a time as `2026-04-10T21:15`. ### Filling the cache ```bash hatebu sync # yesterday hatebu sync --days 30 # yesterday back thirty days hatebu sync --date 2019-07-04 # one day hatebu import ./old-bookmarks # a legacy /YYYY/MM/*.json tree ``` `sync` never fetches today by default: today is still changing, and a cached copy of a half-finished day would be wrong from the moment it was written. `--days` and `--date` cannot be combined. ### Configuration ```bash hatebu config set username hatebu config get username ``` ## Where things live | | | | --- | --- | | Bookmarks | `${XDG_CACHE_HOME:-~/.cache}/hatebucli/YYYY/MM/DD.json` | | Username | `~/.config/hatebu/credentials.json` | | `HATENA_USER` | the user ID, and it wins over the stored one | | `HATENA_BOOKMARK_RSS_URL` | override the feed URL. `%s` is the user ID | The cache is plain JSON per day and safe to copy, diff or back up. Earlier versions also kept a search index under `hatebucli/index`; nothing writes there any more and it can be deleted (see [ADR 005](docs/ADR/005-search-by-scanning.md)). ## What the archive can and cannot tell you - A day that was never synced is not a day with nothing in it, and the commands say which is which rather than reporting a zero. - Tags come from the feed as it stood on the day. A stretch from before you started tagging has no tags, which is a gap in the data and not a gap in your reading; `words` is the tool for those years. - Comments are only present where you left one, which for most people is almost nowhere. - Re-syncing an old day is safe and sometimes an improvement: a legacy import can carry a URL where the title should be, and the feed still has the title. ## MCP server `hatebu --mcp-server` runs the CLI as a Model Context Protocol server over stdio, so an MCP client can read your bookmarks. `--mcp`, `mcp-server` and `mcp` start the same thing. ```bash hatebu --mcp-server ``` It needs a username before it starts, from `hatebu config set username` or from `HATENA_USER` in the client's environment. stdout carries only JSON-RPC; anything meant for a human goes to stderr. Configured in a client: ```json { "mcpServers": { "hatebu": { "command": "node", "args": ["/path/to/hatebucli/bin/hatebu.js", "--mcp-server"], "env": { "HATENA_USER": "your_username" } } } } ``` Once this is on npm the command becomes `npx -y @yuiseki/hatebucli --mcp-server`. Everything except today is answered from the local cache, so run `hatebu sync` before pointing a client at it. ### Tools Fourteen, all read-only. Nothing writes to your account, and there is no sync tool: fetching is a decision about somebody else's server, so it stays a command you run. | Tool | Arguments | | --- | --- | | `hatebu_search` | `query` (required, up to 200 characters), `field`, `match`, `date`, `limit` (default 10, max 100). Returns `match_count` and `returned_count` | | `hatebu_list` | `date` (defaults to today) | | `hatebu_domains` | `date`, `today`, `limit` | | `hatebu_tags` | `date`, `today`, `limit` | | `hatebu_words` | `date`, `today`, `by`, `limit` (default 30) | | `hatebu_stats` | `date`, `days`, `top` | | `hatebu_lookup` | `url_or_domain` (required), `limit` | | `hatebu_timeline` | `by`, `tag`, `domain`, `query`, `from`, `to` | | `hatebu_tagged` | `tag` (required), `limit` | | `hatebu_random` | `count`, `tag`, `domain`, `query`, `from`, `to` | | `hatebu_compare` | `a`, `b` (required), `by` (`words`, `domains`, `tags`), `limit` | | `hatebu_context` | `url_or_time` (required), `minutes`, `gap` | | `hatebu_bursts` | one of `query`, `tag`, `domain` (required), `from`, `to`, `lift`, `min_count` | | `hatebu_resurface` | `limit`, `min_total`, `recent_years`, `peaked_after`, `peaked_before` | They answer the same numbers as the matching command, down to the defaults, and a test holds them to it. What differs is what a model needs and a terminal does not: - Every ranking returns `missing_dates` alongside the numbers, so a model can tell a quiet week from an unsynced one. - `hatebu_list` says whether the day was in the cache at all. - `hatebu_stats` returns the Markdown and the JSON as two blocks, so a model gets both without a second call. The JSON is byte for byte what `stats --json` prints. - `hatebu_search` reports `match_count` as well as `returned_count`, so a question about how many were bookmarked is not answered by counting rows the limit allowed through, `--json` returns, since a title is matched per character and they are single letters; `matchedIn` stays, because which field matched is worth knowing. A bad argument comes back as an error on that call. The server keeps answering. ## Development ```bash npm install npm run build npm test ``` CI runs the same on Node 22, 24 and 26. `npm install` wants npm 11 or newer: npm 10.9.3 cannot resolve this dependency set and dies in arborist with `Cannot read properties of null (reading 'edgesOut')`. `npm ci` against the committed lockfile is fine on either. The tests spawn the built CLI and assert on its output, and the MCP tests speak JSON-RPC to it over a pipe, so they cover the framing too. Nothing in them touches your real cache or reaches Hatena: the fetching paths run against a stub feed served from a second process. To unlink a local build again: ```bash npm unlink -g @yuiseki/hatebucli ``` The version in `package.json` is the only way to tell two builds apart, so bump it whenever a copy goes anywhere: a global install, a directory a service runs from, a tarball. Three copies all claiming the same version is how you end up debugging the wrong one. The decisions behind the shape of this thing are in [docs/ADR](docs/ADR), including why search scans instead of keeping an index and why the MCP server is the CLI rather than a second program.