Metadata-Version: 2.4
Name: xwatch
Version: 0.1.1
Summary: Collect new posts from watched X (Twitter) accounts via the official API.
Project-URL: Homepage, https://github.com/seokhoonj/xwatch
Project-URL: Repository, https://github.com/seokhoonj/xwatch
Project-URL: Issues, https://github.com/seokhoonj/xwatch/issues
Author-email: seokhoonj <seokhoonj@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: collect,digest,notify,posts,twitter,watch,x
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: End Users/Desktop
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Communications
Classifier: Topic :: Internet
Requires-Python: >=3.11
Requires-Dist: pushpush>=0.2
Requires-Dist: requests>=2.31
Requires-Dist: thinchat<0.2,>=0.1
Provides-Extra: dev
Requires-Dist: mypy>=1.11; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Requires-Dist: types-requests; extra == 'dev'
Description-Content-Type: text/markdown

# xwatch

[![check](https://github.com/seokhoonj/xwatch/actions/workflows/check.yml/badge.svg)](https://github.com/seokhoonj/xwatch/actions/workflows/check.yml)
[![PyPI](https://img.shields.io/pypi/v/xwatch)](https://pypi.org/project/xwatch/)
[![Python](https://img.shields.io/pypi/pyversions/xwatch)](https://pypi.org/project/xwatch/)
[![License](https://img.shields.io/pypi/l/xwatch)](https://github.com/seokhoonj/xwatch/blob/main/LICENSE)

**English** | [한국어](README.ko.md)

Collect new posts from watched X (Twitter) accounts via the official X API v2.

xwatch polls the accounts you follow, keeps only the posts each one's text filter
admits, archives them, and can notify you when something new appears. It reads only
*new* posts — the last post id seen per account is passed to the API as `since_id`,
so a poll with nothing new reads (and bills) nothing.

## 1. Cost

xwatch reads the **official X API, which is pay-per-use** — about **$0.005 per post**
returned (as of 2026-07-28; a 2M-read monthly cap). What that means in practice:

- **Watching** a handful of accounts costs a few dollars a month: a poll reads only posts
  newer than the last one seen, so an idle account costs nothing, and dropping
  replies/retweets at the API cuts the read further.
- **Backfilling** an account reads up to its most recent ~3,200 posts once — about **$16** at
  the cap; `--max-posts N` bounds it.
- The **LLM features** (summary, classification, translation) run on Gemini's free tier by
  default — no charge — and **downloading media** is a plain HTTP fetch, not the X API, so it
  is free too.

Client-side filtering (keywords, the ad filter) does **not** save reads — you already paid to
read the post; only `--no-replies` / `--no-retweets` avoid the read at the API. Prices change,
so check the current rates in the X developer portal (<https://developer.x.com/en/portal/products>).

## 2. Install

```sh
pip install xwatch
```

That is everything — collecting and archiving, notifying to Telegram / Slack / Discord
(via pushpush), and the LLM features (summary, classification, translation). No extras to
pick.

## 3. Set up

xwatch talks to the X API with a **bearer token**. Create a project and app in the X
developer portal — <https://developer.x.com/en/portal/dashboard> — and copy its Bearer
Token (the "Keys and tokens" tab).

Store the token where xwatch looks for it — a 0600 JSON file beside its config, or
the environment:

```sh
mkdir -p ~/.config/xwatch
printf '{"X_BEARER_TOKEN": "%s"}\n' "$YOUR_TOKEN" > ~/.config/xwatch/credentials.json
chmod 600 ~/.config/xwatch/credentials.json
# or, one-off:  export X_BEARER_TOKEN=...
```

## 4. Command-line usage

```sh
xwatch @nasa                       # print @nasa's latest posts (no store)
xwatch add nasa                    # start watching an account (from now on)
xwatch add BTS_twt --no-replies    # skip that account's replies (drop conversational noise)
xwatch add realDonaldTrump --route telegram # deliver this account's posts to a channel
xwatch accounts                    # list watched accounts
xwatch poll                        # collect every account's new posts once
xwatch poll --translate Korean     # deliver each post translated, above the original
xwatch poll --filter-ads           # drop promotional posts from delivery (still archived)
xwatch poll --no-notify            # archive new posts without sending any notification
xwatch watch --every 10            # poll every 10 minutes in the foreground
xwatch posts --handle nasa --since 2026-07-01
xwatch schedule install --every 15 # run `xwatch poll` from cron every 15 min
```

`add` starts watching **from now**: it marks the account's current newest post as the
starting point, so the first `poll` collects only posts published afterwards, not a
backfill of the recent timeline. Pass `--backfill` to opt into collecting recent posts
on the first poll instead.

**Delivery is opt-in per account.** A `--route` names a route you set up in pushpush — a
channel on Telegram, Slack, or Discord, named whatever you called it there. That account's
new posts are sent to it; an account with no route is archive-only, collected and stored but
never sent. So a plain `xwatch add nasa` watches and archives quietly, and you add a route
(e.g. `--route telegram`) to the accounts you want pushed to you.

## 5. Coding agents

xwatch is also an installable plugin for **Claude Code** and **Codex** — this repo doubles
as a plugin marketplace. The plugin only shells out to the `xwatch` command, so install the
CLI first; your token stays in your own credentials file.

**Claude Code**

```
/plugin marketplace add seokhoonj/xwatch
/plugin install xwatch@xwatch
```

**Codex**

```
codex plugin marketplace add seokhoonj/xwatch
codex plugin add xwatch@xwatch
```

Then just ask — "watch @nasa and show its new posts". The skill confirms any billed X API
read before it runs.

## 6. Python usage

The CLI is a thin shell over the library, so you can drive the same pipeline directly:

```python
from xwatch import make_client, load_accounts, poll_accounts, read_state, FileStore

client = make_client()                       # bearer token from the credentials store
report = poll_accounts(load_accounts(), client, read_state(), store=FileStore())   # collect, archive, advance watermarks
for post in report.deliverable:
    print(post.author, post.text[:80])
```

A collected `Post` carries the full text and its captured payload; small helpers read the parts:

```python
from xwatch import cashtags, media_urls, translate_post, classify_ad

for post in report.deliverable:
    print(cashtags(post))                                # ("MU", "DRAM") -- tickers, bare
    print(media_urls(post))                              # image / video-thumbnail URLs
    print(translate_post(post, target_language="Korean").text)
    print(classify_ad(post).is_ad)                       # LLM ad judgment
```

For a one-off pull without watching, `make_client()` gives a `Client` with `resolve_user`
and `fetch_new_posts`; the archive is a `FileStore` you can query. `import xwatch;
help(xwatch)` lists the full surface.

## 7. Filtering an account's posts

Each account can narrow what it collects:

- `--no-replies` / `--no-retweets` drop that kind **at the API**, so they are never
  fetched (cheaper and cleaner than discarding them after).
- `--include WORD` keeps only posts whose text contains every listed word;
  `--exclude WORD` drops any post whose text contains a listed word (both
  case-insensitive, repeatable). Good for cutting promotional posts:

```sh
xwatch add BTS_twt --no-replies --exclude sponsored --exclude ad
```

These live in `accounts.toml`, so you can also edit them by hand:

```toml
[[account]]
handle          = "BTS_twt"
include_replies = false
excludes        = ["sponsored", "ad"]
```

Note: the `--include`/`--exclude` **text** filters trim only *delivery* — every fetched
post is archived regardless, and the read is unchanged (the timeline fetch still carries
those posts; the X API has no server-side text filter), which for a handful of accounts is
negligible. `--no-replies`/`--no-retweets`, by contrast, drop at the API and so cut both
the read and what is archived.

## 8. Ad classification with an LLM

The keyword filter only catches words you listed, so a heavily-promoting account
defeats it both ways: its ad vocabulary is product names and calls to action rather
than a fixed keyword set, so real ads slip through while an ordinary post that happens
to contain a listed word is wrongly dropped. `xwatch classify` judges each archived
post by its meaning instead — a small model returns is-ad plus a one-line reason — and
saves the verdict to the post's record, so the judgment is made (and billed) once and
reused:

```sh
xwatch classify --handle trader        # judge this account's archived posts, save the verdicts
xwatch classify --limit 200            # only the most recent 200 (one LLM call each)
xwatch classify --reclassify           # re-judge posts that already have a verdict
xwatch classify --provider claude      # use Claude instead of the default (Gemini)
xwatch posts --handle trader --no-ads  # hide the ads; --ads shows only them
```

A post already classified is skipped on the next run, so re-running `classify` only
spends on newly collected posts. `posts --ads`/`--no-ads` uses a post's stored verdict
when it has one and falls back to the account's keyword filter otherwise — so you can
classify only the accounts that need it and leave the rest on keywords.

The backend is pluggable (it runs on the thinchat library): the default is Google's Gemini
free tier (no per-call charge); `--provider claude` (or `openai`, `ollama`) switches, and
`--model` overrides the model. Classifying needs an API key for the chosen provider
(`GEMINI_API_KEY`, `CLAUDE_API_KEY`, ..., the same keys the `summary` feature uses), set in
the environment or the credentials file. On a paid backend each post is one small call, so
`--limit` bounds the spend; on the free tier the daily request cap does.

## 9. Translated, ad-filtered delivery

A poll can reshape each notification as it goes out, both opt-in and both running on the
same LLM backend as `classify`:

- `--translate LANGUAGE` renders each post into a language, shown above the original so
  the source stays for reference:

  ```
  @trader

  시장이 조정 국면에 들어섰습니다. 현금 비중을 높이세요.
  ──────────
  The market has entered a correction. Raise cash.
  https://x.com/trader/status/…
  ```

- `--filter-ads` runs the ad judgment at send time and does not deliver a post it judges
  promotional. The post is still archived and its verdict stored — nothing is lost, the
  notifications are just quieter.

Both degrade safely: a translation or classification failure delivers the original post
rather than dropping it, so an LLM outage never stalls a watch. Turn either on permanently
for a scheduled poll via config.toml, so a cron `xwatch poll` picks it up with no flags:

```toml
translate  = "Korean"    # any language name -- "Spanish", "Japanese", ...
filter_ads = true
```

## 10. What each post keeps

The archive stores the whole post, not just its visible text: the full body of a long
"note" tweet (not the truncated preview), a retweet's original text, and the post's media,
engagement metrics, and entities — cashtags ($MU, $DRAM), hashtags, mentions, and the
expanded links behind its t.co shorteners. Downloaded image and video-thumbnail files live
beside the posts under `archive/media/`, keyed so an image shared across a retweet is stored
once.

## 11. How it stays cheap and correct

- **`since_id` bounds every fetch.** Only posts newer than the last one seen come
  back; an idle account costs nothing. Dropping replies/retweets at the API keeps
  even a chatty account cheap.
- **The watermark advances past every fetched post**, even ones the text filter
  drops — so a filtered-out post is never re-fetched, and no post is delivered twice.
- **The resolved user id is cached** per handle, so a poll never re-pays to look up
  an account it already knows.
- **Files are split by kind** (the XDG layout): hand-editable config and the token in
  `~/.config/xwatch`, the archive in `~/.local/share/xwatch`, run state in
  `~/.local/state/xwatch`. Resetting settings never touches the archive.

## 12. Where things live

| Path | What |
|---|---|
| `~/.config/xwatch/accounts.toml` | the watched accounts (hand-editable) |
| `~/.config/xwatch/config.toml` | non-secret settings (`translate`, `filter_ads`, data dir) |
| `~/.config/xwatch/credentials.json` | the bearer token, and any LLM provider keys (0600) |
| `~/.local/share/xwatch/archive/posts/` | the collected posts, one JSON file each |
| `~/.local/share/xwatch/archive/media/` | downloaded image / video-thumbnail files |
| `~/.local/state/xwatch/state.json` | the per-account since-id watermarks and user-id cache |

## 13. License

MIT
