xwatch 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xwatch-0.1.0/.agents/plugins/marketplace.json +12 -0
- xwatch-0.1.0/.claude-plugin/marketplace.json +17 -0
- xwatch-0.1.0/.github/workflows/check.yml +24 -0
- xwatch-0.1.0/.github/workflows/publish.yml +71 -0
- xwatch-0.1.0/.github/workflows/reusable-check.yml +102 -0
- xwatch-0.1.0/.gitignore +33 -0
- xwatch-0.1.0/LICENSE +21 -0
- xwatch-0.1.0/PKG-INFO +297 -0
- xwatch-0.1.0/README.ko.md +252 -0
- xwatch-0.1.0/README.md +265 -0
- xwatch-0.1.0/plugins/xwatch/.claude-plugin/plugin.json +11 -0
- xwatch-0.1.0/plugins/xwatch/.codex-plugin/plugin.json +20 -0
- xwatch-0.1.0/plugins/xwatch/skills/watch/SKILL.md +110 -0
- xwatch-0.1.0/pyproject.toml +74 -0
- xwatch-0.1.0/src/xwatch/__init__.py +121 -0
- xwatch-0.1.0/src/xwatch/__main__.py +10 -0
- xwatch-0.1.0/src/xwatch/_llm.py +57 -0
- xwatch-0.1.0/src/xwatch/accounts.py +219 -0
- xwatch-0.1.0/src/xwatch/backfill.py +113 -0
- xwatch-0.1.0/src/xwatch/classify.py +114 -0
- xwatch-0.1.0/src/xwatch/cli.py +601 -0
- xwatch-0.1.0/src/xwatch/client.py +455 -0
- xwatch-0.1.0/src/xwatch/collect.py +113 -0
- xwatch-0.1.0/src/xwatch/config.py +223 -0
- xwatch-0.1.0/src/xwatch/credentials.py +122 -0
- xwatch-0.1.0/src/xwatch/errors.py +96 -0
- xwatch-0.1.0/src/xwatch/handles.py +69 -0
- xwatch-0.1.0/src/xwatch/media.py +192 -0
- xwatch-0.1.0/src/xwatch/notify.py +79 -0
- xwatch-0.1.0/src/xwatch/poll.py +179 -0
- xwatch-0.1.0/src/xwatch/posts.py +113 -0
- xwatch-0.1.0/src/xwatch/py.typed +0 -0
- xwatch-0.1.0/src/xwatch/schedule.py +265 -0
- xwatch-0.1.0/src/xwatch/state.py +136 -0
- xwatch-0.1.0/src/xwatch/store.py +314 -0
- xwatch-0.1.0/src/xwatch/summary.py +63 -0
- xwatch-0.1.0/src/xwatch/translate.py +86 -0
- xwatch-0.1.0/tests/__init__.py +0 -0
- xwatch-0.1.0/tests/conftest.py +63 -0
- xwatch-0.1.0/tests/test_accounts.py +106 -0
- xwatch-0.1.0/tests/test_backfill.py +137 -0
- xwatch-0.1.0/tests/test_classify.py +105 -0
- xwatch-0.1.0/tests/test_cli.py +453 -0
- xwatch-0.1.0/tests/test_client.py +228 -0
- xwatch-0.1.0/tests/test_collect.py +104 -0
- xwatch-0.1.0/tests/test_config.py +49 -0
- xwatch-0.1.0/tests/test_credentials.py +57 -0
- xwatch-0.1.0/tests/test_handles.py +51 -0
- xwatch-0.1.0/tests/test_llm.py +70 -0
- xwatch-0.1.0/tests/test_media.py +94 -0
- xwatch-0.1.0/tests/test_notify.py +100 -0
- xwatch-0.1.0/tests/test_packaging.py +28 -0
- xwatch-0.1.0/tests/test_poll.py +189 -0
- xwatch-0.1.0/tests/test_posts.py +58 -0
- xwatch-0.1.0/tests/test_schedule.py +55 -0
- xwatch-0.1.0/tests/test_state.py +71 -0
- xwatch-0.1.0/tests/test_store.py +157 -0
- xwatch-0.1.0/tests/test_summary.py +75 -0
- xwatch-0.1.0/tests/test_translate.py +86 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "xwatch",
|
|
3
|
+
"interface": { "displayName": "xwatch" },
|
|
4
|
+
"plugins": [
|
|
5
|
+
{
|
|
6
|
+
"name": "xwatch",
|
|
7
|
+
"source": { "source": "local", "path": "./plugins/xwatch" },
|
|
8
|
+
"policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" },
|
|
9
|
+
"category": "Productivity"
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "xwatch",
|
|
3
|
+
"owner": { "name": "seokhoonj" },
|
|
4
|
+
"description": "Watch X (Twitter) accounts and collect their new posts via the official API",
|
|
5
|
+
"plugins": [
|
|
6
|
+
{
|
|
7
|
+
"name": "xwatch",
|
|
8
|
+
"displayName": "xwatch",
|
|
9
|
+
"source": "./plugins/xwatch",
|
|
10
|
+
"description": "Watch X (Twitter) accounts and collect their new posts",
|
|
11
|
+
"author": { "name": "seokhoonj" },
|
|
12
|
+
"homepage": "https://github.com/seokhoonj/xwatch",
|
|
13
|
+
"category": "Productivity",
|
|
14
|
+
"keywords": ["x", "twitter", "posts", "watch", "collect"]
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: check
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
push:
|
|
6
|
+
branches: [main]
|
|
7
|
+
pull_request:
|
|
8
|
+
|
|
9
|
+
# A newer push to the same branch or PR makes the older run's result obsolete,
|
|
10
|
+
# so cancel it rather than let both run to completion.
|
|
11
|
+
concurrency:
|
|
12
|
+
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
|
|
13
|
+
cancel-in-progress: true
|
|
14
|
+
|
|
15
|
+
# The check only reads the code to lint, type, and test it -- it never writes to the
|
|
16
|
+
# repo -- so it runs with the least privilege that still allows a checkout.
|
|
17
|
+
permissions:
|
|
18
|
+
contents: read
|
|
19
|
+
|
|
20
|
+
jobs:
|
|
21
|
+
check:
|
|
22
|
+
# The full check lives in reusable-check.yml so the release gate in
|
|
23
|
+
# publish.yml runs the identical job.
|
|
24
|
+
uses: ./.github/workflows/reusable-check.yml
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
# Publish to PyPI on a GitHub Release, via PyPI Trusted Publishing (OIDC): no API
|
|
4
|
+
# token or password is stored anywhere -- GitHub mints a short-lived identity token
|
|
5
|
+
# that PyPI verifies against the publisher registered for this repo (owner seokhoonj
|
|
6
|
+
# / repo xwatch / this workflow / environment "pypi").
|
|
7
|
+
#
|
|
8
|
+
# Three jobs. `gate` runs the same reusable check that check.yml runs, on the
|
|
9
|
+
# release commit, so a red build (a lint/type error, a broken
|
|
10
|
+
# test, a lost py.typed or console script) cannot reach an upload. `build` builds
|
|
11
|
+
# the sdist and wheel and hands them to `publish` as an artifact. `publish` -- the
|
|
12
|
+
# only job holding the OIDC token -- downloads that artifact and uploads it, and
|
|
13
|
+
# checks out no source, so the privileged step runs the least code.
|
|
14
|
+
|
|
15
|
+
on:
|
|
16
|
+
release:
|
|
17
|
+
types: [published]
|
|
18
|
+
|
|
19
|
+
# Workflow-level floor: any job without its own `permissions:` block still gets a
|
|
20
|
+
# read-only token, so a job added later cannot silently inherit a write-capable
|
|
21
|
+
# default. The build and publish jobs narrow it further below.
|
|
22
|
+
permissions:
|
|
23
|
+
contents: read
|
|
24
|
+
|
|
25
|
+
jobs:
|
|
26
|
+
gate:
|
|
27
|
+
uses: ./.github/workflows/reusable-check.yml
|
|
28
|
+
|
|
29
|
+
build:
|
|
30
|
+
needs: gate
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
timeout-minutes: 15
|
|
33
|
+
permissions:
|
|
34
|
+
contents: read
|
|
35
|
+
steps:
|
|
36
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
37
|
+
with:
|
|
38
|
+
persist-credentials: false
|
|
39
|
+
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
40
|
+
- name: Assert the release tag matches the package version
|
|
41
|
+
# A release tagged v0.2.0 while pyproject still says 0.1.0 would publish the wrong
|
|
42
|
+
# version -- and a PyPI version number can never be reused. Fail before the build.
|
|
43
|
+
# The tag is passed through env, never interpolated into the script, so a crafted
|
|
44
|
+
# tag name cannot inject shell.
|
|
45
|
+
env:
|
|
46
|
+
TAG: ${{ github.event.release.tag_name }}
|
|
47
|
+
run: |
|
|
48
|
+
version=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
|
|
49
|
+
test "${TAG#v}" = "$version" || { echo "release tag '$TAG' does not match package version '$version'"; exit 1; }
|
|
50
|
+
- name: Build sdist and wheel, then check the metadata
|
|
51
|
+
run: |
|
|
52
|
+
uv build
|
|
53
|
+
uvx twine check dist/*
|
|
54
|
+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
55
|
+
with:
|
|
56
|
+
name: dist
|
|
57
|
+
path: dist/
|
|
58
|
+
|
|
59
|
+
publish:
|
|
60
|
+
needs: build
|
|
61
|
+
runs-on: ubuntu-latest
|
|
62
|
+
timeout-minutes: 15
|
|
63
|
+
environment: pypi
|
|
64
|
+
permissions:
|
|
65
|
+
id-token: write # OIDC; this is what replaces a stored token
|
|
66
|
+
steps:
|
|
67
|
+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
68
|
+
with:
|
|
69
|
+
name: dist
|
|
70
|
+
path: dist/
|
|
71
|
+
- uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
name: reusable check
|
|
2
|
+
|
|
3
|
+
# The full check -- tests, lint, types, and the packaging assertions -- factored
|
|
4
|
+
# into one reusable job so both `check.yml` (on push / PR) and the release gate in
|
|
5
|
+
# `publish.yml` run the *identical* thing. One definition is what makes "publish
|
|
6
|
+
# gates on the full check" true; a second hand-maintained copy would drift, and a
|
|
7
|
+
# check added here silently would not gate releases.
|
|
8
|
+
|
|
9
|
+
on:
|
|
10
|
+
workflow_call:
|
|
11
|
+
|
|
12
|
+
# The floor lives on the shared body, not only on `check.yml`: `publish.yml`'s gate
|
|
13
|
+
# calls this same workflow, and if the floor were only on `check.yml` the gate would
|
|
14
|
+
# run this check with the repo-default (possibly write) token. Declared here, the
|
|
15
|
+
# check is read-only from every caller. A reusable workflow can only downscope from
|
|
16
|
+
# the caller, so this never widens anyone's token.
|
|
17
|
+
permissions:
|
|
18
|
+
contents: read
|
|
19
|
+
|
|
20
|
+
jobs:
|
|
21
|
+
check:
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
timeout-minutes: 15
|
|
24
|
+
strategy:
|
|
25
|
+
fail-fast: false
|
|
26
|
+
matrix:
|
|
27
|
+
# The floor and the current release. requires-python says >=3.11, so 3.11 is
|
|
28
|
+
# the version that claim has to actually hold on; the upper end tracks whatever
|
|
29
|
+
# CPython currently ships, since >=3.11 promises every release above it.
|
|
30
|
+
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
|
31
|
+
|
|
32
|
+
steps:
|
|
33
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
34
|
+
with:
|
|
35
|
+
persist-credentials: false
|
|
36
|
+
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
37
|
+
|
|
38
|
+
- name: Install
|
|
39
|
+
# A venv per matrix version, not `uv pip --system`: the runner's system Python
|
|
40
|
+
# is externally managed and is one fixed version whatever the matrix says. This
|
|
41
|
+
# is also what the README tells a person to run.
|
|
42
|
+
run: |
|
|
43
|
+
uv venv --python ${{ matrix.python-version }}
|
|
44
|
+
uv pip install -e ".[dev]"
|
|
45
|
+
.venv/bin/python -c "import sys; print('testing on', sys.version)"
|
|
46
|
+
|
|
47
|
+
- name: Test
|
|
48
|
+
run: .venv/bin/pytest -q
|
|
49
|
+
|
|
50
|
+
- name: Lint
|
|
51
|
+
run: .venv/bin/ruff check src tests
|
|
52
|
+
|
|
53
|
+
- name: Types
|
|
54
|
+
run: .venv/bin/mypy
|
|
55
|
+
|
|
56
|
+
- name: Confirm the runtime dependencies are exactly what is declared
|
|
57
|
+
# xwatch's runtime dependencies are pushpush (delivery to Telegram / Slack /
|
|
58
|
+
# Discord), requests (the X API), and thinchat (the LLM layer behind summary /
|
|
59
|
+
# classify / translate) -- so a bare `pip install xwatch` collects, notifies, and
|
|
60
|
+
# runs the LLM features with no extras. A dependency that crept in -- or one
|
|
61
|
+
# dropping out of the built metadata -- would change what that install pulls, so it
|
|
62
|
+
# is asserted here rather than trusted.
|
|
63
|
+
run: |
|
|
64
|
+
uv venv /tmp/bare --python ${{ matrix.python-version }}
|
|
65
|
+
uv pip install --python /tmp/bare/bin/python .
|
|
66
|
+
/tmp/bare/bin/python -c "
|
|
67
|
+
import importlib.metadata as md, re
|
|
68
|
+
requires = md.requires('xwatch') or []
|
|
69
|
+
runtime = [r for r in requires if 'extra ==' not in r]
|
|
70
|
+
names = sorted(re.match(r'[A-Za-z0-9._-]+', r).group(0).lower() for r in runtime)
|
|
71
|
+
assert names == ['pushpush', 'requests', 'thinchat'], f'unexpected runtime dependencies: {runtime}'
|
|
72
|
+
import xwatch
|
|
73
|
+
print('xwatch', xwatch.__version__, 'runtime deps:', names)
|
|
74
|
+
"
|
|
75
|
+
|
|
76
|
+
- name: Confirm the console script installed
|
|
77
|
+
# pyproject declares an `xwatch` entry point; a wheel that dropped it would
|
|
78
|
+
# leave the CLI, the skill, and the docs pointing at a command that is not
|
|
79
|
+
# there. `--help` is the cheapest call that proves it runs.
|
|
80
|
+
run: |
|
|
81
|
+
uv venv /tmp/cli --python ${{ matrix.python-version }}
|
|
82
|
+
uv pip install --python /tmp/cli/bin/python .
|
|
83
|
+
/tmp/cli/bin/xwatch --help > /dev/null
|
|
84
|
+
|
|
85
|
+
- name: Confirm a user's type checker can see the hints
|
|
86
|
+
# Every hint in this package is invisible to a user unless py.typed ships
|
|
87
|
+
# alongside it (PEP 561), and the source cannot answer whether it did:
|
|
88
|
+
# src/xwatch/py.typed can sit in git while the built wheel omits it. So ask it
|
|
89
|
+
# the way a user does -- install the built package into a clean environment and
|
|
90
|
+
# run their checker over their code.
|
|
91
|
+
run: |
|
|
92
|
+
uv venv /tmp/typed --python ${{ matrix.python-version }}
|
|
93
|
+
uv pip install --python /tmp/typed/bin/python . mypy
|
|
94
|
+
cat > /tmp/user_code.py <<'PY'
|
|
95
|
+
from xwatch import normalize_handle
|
|
96
|
+
|
|
97
|
+
normalize_handle(123) # raw is str; 123 is not one
|
|
98
|
+
PY
|
|
99
|
+
/tmp/typed/bin/mypy /tmp/user_code.py > /tmp/mypy_out 2>&1 || true
|
|
100
|
+
cat /tmp/mypy_out
|
|
101
|
+
grep -q 'incompatible type "int"' /tmp/mypy_out
|
|
102
|
+
grep -q '"str"' /tmp/mypy_out
|
xwatch-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
.env
|
|
11
|
+
|
|
12
|
+
# uv
|
|
13
|
+
uv.lock
|
|
14
|
+
|
|
15
|
+
# Tooling caches
|
|
16
|
+
.pytest_cache/
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
.coverage
|
|
20
|
+
htmlcov/
|
|
21
|
+
|
|
22
|
+
# Local scratch (never tracked)
|
|
23
|
+
dev/
|
|
24
|
+
refs/
|
|
25
|
+
|
|
26
|
+
# AI coding agents
|
|
27
|
+
CLAUDE.md
|
|
28
|
+
.claude/
|
|
29
|
+
AGENTS.md
|
|
30
|
+
AGENT.md
|
|
31
|
+
.codex/
|
|
32
|
+
GEMINI.md
|
|
33
|
+
.gemini/
|
xwatch-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 seokhoonj
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
xwatch-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xwatch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Collect new posts from watched X (Twitter) accounts via the official API.
|
|
5
|
+
Project-URL: Homepage, https://github.com/seokhoonj/xwatch
|
|
6
|
+
Project-URL: Repository, https://github.com/seokhoonj/xwatch
|
|
7
|
+
Project-URL: Issues, https://github.com/seokhoonj/xwatch/issues
|
|
8
|
+
Author-email: seokhoonj <seokhoonj@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: collect,digest,notify,posts,twitter,watch,x
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Communications
|
|
21
|
+
Classifier: Topic :: Internet
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: pushpush>=0.2
|
|
24
|
+
Requires-Dist: requests>=2.31
|
|
25
|
+
Requires-Dist: thinchat<0.2,>=0.1
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
30
|
+
Requires-Dist: types-requests; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# xwatch
|
|
34
|
+
|
|
35
|
+
[](https://github.com/seokhoonj/xwatch/actions/workflows/check.yml)
|
|
36
|
+
[](https://pypi.org/project/xwatch/)
|
|
37
|
+
[](https://pypi.org/project/xwatch/)
|
|
38
|
+
[](https://github.com/seokhoonj/xwatch/blob/main/LICENSE)
|
|
39
|
+
|
|
40
|
+
**English** | [한국어](README.ko.md)
|
|
41
|
+
|
|
42
|
+
Collect new posts from watched X (Twitter) accounts via the official X API v2.
|
|
43
|
+
|
|
44
|
+
xwatch polls the accounts you follow, keeps only the posts each one's text filter
|
|
45
|
+
admits, archives them, and can notify you when something new appears. It reads only
|
|
46
|
+
*new* posts — the last post id seen per account is passed to the API as `since_id`,
|
|
47
|
+
so a poll with nothing new reads (and bills) nothing.
|
|
48
|
+
|
|
49
|
+
## 1. Cost
|
|
50
|
+
|
|
51
|
+
xwatch reads the **official X API, which is pay-per-use** — about **$0.005 per post**
|
|
52
|
+
returned (as of 2026-07-28; a 2M-read monthly cap). What that means in practice:
|
|
53
|
+
|
|
54
|
+
- **Watching** a handful of accounts costs a few dollars a month: a poll reads only posts
|
|
55
|
+
newer than the last one seen, so an idle account costs nothing, and dropping
|
|
56
|
+
replies/retweets at the API cuts the read further.
|
|
57
|
+
- **Backfilling** an account reads up to its most recent ~3,200 posts once — about **$16** at
|
|
58
|
+
the cap; `--max-posts N` bounds it.
|
|
59
|
+
- The **LLM features** (summary, classification, translation) run on Gemini's free tier by
|
|
60
|
+
default — no charge — and **downloading media** is a plain HTTP fetch, not the X API, so it
|
|
61
|
+
is free too.
|
|
62
|
+
|
|
63
|
+
Client-side filtering (keywords, the ad filter) does **not** save reads — you already paid to
|
|
64
|
+
read the post; only `--no-replies` / `--no-retweets` avoid the read at the API. Prices change,
|
|
65
|
+
so check the current rates in the X developer portal (<https://developer.x.com/en/portal/products>).
|
|
66
|
+
|
|
67
|
+
## 2. Install
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
pip install xwatch
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
That is everything — collecting and archiving, notifying to Telegram / Slack / Discord
|
|
74
|
+
(via pushpush), and the LLM features (summary, classification, translation). No extras to
|
|
75
|
+
pick.
|
|
76
|
+
|
|
77
|
+
## 3. Set up
|
|
78
|
+
|
|
79
|
+
xwatch talks to the X API with a **bearer token**. Create a project and app in the X
|
|
80
|
+
developer portal — <https://developer.x.com/en/portal/dashboard> — and copy its Bearer
|
|
81
|
+
Token (the "Keys and tokens" tab).
|
|
82
|
+
|
|
83
|
+
Store the token where xwatch looks for it — a 0600 JSON file beside its config, or
|
|
84
|
+
the environment:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
mkdir -p ~/.config/xwatch
|
|
88
|
+
printf '{"X_BEARER_TOKEN": "%s"}\n' "$YOUR_TOKEN" > ~/.config/xwatch/credentials.json
|
|
89
|
+
chmod 600 ~/.config/xwatch/credentials.json
|
|
90
|
+
# or, one-off: export X_BEARER_TOKEN=...
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## 4. Command-line usage
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
xwatch @nasa # print @nasa's latest posts (no store)
|
|
97
|
+
xwatch add nasa # start watching an account (from now on)
|
|
98
|
+
xwatch add BTS_twt --no-replies # skip that account's replies (drop conversational noise)
|
|
99
|
+
xwatch add realDonaldTrump --route telegram # deliver this account's posts to a channel
|
|
100
|
+
xwatch accounts # list watched accounts
|
|
101
|
+
xwatch poll # collect every account's new posts once
|
|
102
|
+
xwatch poll --translate Korean # deliver each post translated, above the original
|
|
103
|
+
xwatch poll --filter-ads # drop promotional posts from delivery (still archived)
|
|
104
|
+
xwatch poll --no-notify # archive new posts without sending any notification
|
|
105
|
+
xwatch watch --every 10 # poll every 10 minutes in the foreground
|
|
106
|
+
xwatch posts --handle nasa --since 2026-07-01
|
|
107
|
+
xwatch schedule install --every 15 # run `xwatch poll` from cron every 15 min
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`add` starts watching **from now**: it marks the account's current newest post as the
|
|
111
|
+
starting point, so the first `poll` collects only posts published afterwards, not a
|
|
112
|
+
backfill of the recent timeline. Pass `--backfill` to opt into collecting recent posts
|
|
113
|
+
on the first poll instead.
|
|
114
|
+
|
|
115
|
+
**Delivery is opt-in per account.** A `--route` names a route you set up in pushpush — a
|
|
116
|
+
channel on Telegram, Slack, or Discord, named whatever you called it there. That account's
|
|
117
|
+
new posts are sent to it; an account with no route is archive-only, collected and stored but
|
|
118
|
+
never sent. So a plain `xwatch add nasa` watches and archives quietly, and you add a route
|
|
119
|
+
(e.g. `--route telegram`) to the accounts you want pushed to you.
|
|
120
|
+
|
|
121
|
+
## 5. Coding agents
|
|
122
|
+
|
|
123
|
+
xwatch is also an installable plugin for **Claude Code** and **Codex** — this repo doubles
|
|
124
|
+
as a plugin marketplace. The plugin only shells out to the `xwatch` command, so install the
|
|
125
|
+
CLI first; your token stays in your own credentials file.
|
|
126
|
+
|
|
127
|
+
**Claude Code**
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
/plugin marketplace add seokhoonj/xwatch
|
|
131
|
+
/plugin install xwatch@xwatch
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**Codex**
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
codex plugin marketplace add seokhoonj/xwatch
|
|
138
|
+
codex plugin add xwatch@xwatch
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Then just ask — "watch @nasa and show its new posts". The skill confirms any billed X API
|
|
142
|
+
read before it runs.
|
|
143
|
+
|
|
144
|
+
## 6. Python usage
|
|
145
|
+
|
|
146
|
+
The CLI is a thin shell over the library, so you can drive the same pipeline directly:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from xwatch import make_client, load_accounts, poll_accounts, read_state, FileStore
|
|
150
|
+
|
|
151
|
+
client = make_client() # bearer token from the credentials store
|
|
152
|
+
report = poll_accounts(load_accounts(), client, read_state(), store=FileStore()) # collect, archive, advance watermarks
|
|
153
|
+
for post in report.deliverable:
|
|
154
|
+
print(post.author, post.text[:80])
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
A collected `Post` carries the full text and its captured payload; small helpers read the parts:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
from xwatch import cashtags, media_urls, translate_post, classify_ad
|
|
161
|
+
|
|
162
|
+
for post in report.deliverable:
|
|
163
|
+
print(cashtags(post)) # ("MU", "DRAM") -- tickers, bare
|
|
164
|
+
print(media_urls(post)) # image / video-thumbnail URLs
|
|
165
|
+
print(translate_post(post, target_language="Korean").text)
|
|
166
|
+
print(classify_ad(post).is_ad) # LLM ad judgment
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
For a one-off pull without watching, `make_client()` gives a `Client` with `resolve_user`
|
|
170
|
+
and `fetch_new_posts`; the archive is a `FileStore` you can query. `import xwatch;
|
|
171
|
+
help(xwatch)` lists the full surface.
|
|
172
|
+
|
|
173
|
+
## 7. Filtering an account's posts
|
|
174
|
+
|
|
175
|
+
Each account can narrow what it collects:
|
|
176
|
+
|
|
177
|
+
- `--no-replies` / `--no-retweets` drop that kind **at the API**, so they are never
|
|
178
|
+
fetched (cheaper and cleaner than discarding them after).
|
|
179
|
+
- `--include WORD` keeps only posts whose text contains every listed word;
|
|
180
|
+
`--exclude WORD` drops any post whose text contains a listed word (both
|
|
181
|
+
case-insensitive, repeatable). Good for cutting promotional posts:
|
|
182
|
+
|
|
183
|
+
```sh
|
|
184
|
+
xwatch add BTS_twt --no-replies --exclude sponsored --exclude ad
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
These live in `accounts.toml`, so you can also edit them by hand:
|
|
188
|
+
|
|
189
|
+
```toml
|
|
190
|
+
[[account]]
|
|
191
|
+
handle = "BTS_twt"
|
|
192
|
+
include_replies = false
|
|
193
|
+
excludes = ["sponsored", "ad"]
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Note: the `--include`/`--exclude` **text** filters trim only *delivery* — every fetched
|
|
197
|
+
post is archived regardless, and the read is unchanged (the timeline fetch still carries
|
|
198
|
+
those posts; the X API has no server-side text filter), which for a handful of accounts is
|
|
199
|
+
negligible. `--no-replies`/`--no-retweets`, by contrast, drop at the API and so cut both
|
|
200
|
+
the read and what is archived.
|
|
201
|
+
|
|
202
|
+
## 8. Ad classification with an LLM
|
|
203
|
+
|
|
204
|
+
The keyword filter only catches words you listed, so a heavily-promoting account
|
|
205
|
+
defeats it both ways: its ad vocabulary is product names and calls to action rather
|
|
206
|
+
than a fixed keyword set, so real ads slip through while an ordinary post that happens
|
|
207
|
+
to contain a listed word is wrongly dropped. `xwatch classify` judges each archived
|
|
208
|
+
post by its meaning instead — a small model returns is-ad plus a one-line reason — and
|
|
209
|
+
saves the verdict to the post's record, so the judgment is made (and billed) once and
|
|
210
|
+
reused:
|
|
211
|
+
|
|
212
|
+
```sh
|
|
213
|
+
xwatch classify --handle trader # judge this account's archived posts, save the verdicts
|
|
214
|
+
xwatch classify --limit 200 # only the most recent 200 (one LLM call each)
|
|
215
|
+
xwatch classify --reclassify # re-judge posts that already have a verdict
|
|
216
|
+
xwatch classify --provider claude # use Claude instead of the default (Gemini)
|
|
217
|
+
xwatch posts --handle trader --no-ads # hide the ads; --ads shows only them
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
A post already classified is skipped on the next run, so re-running `classify` only
|
|
221
|
+
spends on newly collected posts. `posts --ads`/`--no-ads` uses a post's stored verdict
|
|
222
|
+
when it has one and falls back to the account's keyword filter otherwise — so you can
|
|
223
|
+
classify only the accounts that need it and leave the rest on keywords.
|
|
224
|
+
|
|
225
|
+
The backend is pluggable (it runs on the thinchat library): the default is Google's Gemini
|
|
226
|
+
free tier (no per-call charge); `--provider claude` (or `openai`, `ollama`) switches, and
|
|
227
|
+
`--model` overrides the model. Classifying needs an API key for the chosen provider
|
|
228
|
+
(`GEMINI_API_KEY`, `CLAUDE_API_KEY`, ..., the same keys the `summary` feature uses), set in
|
|
229
|
+
the environment or the credentials file. On a paid backend each post is one small call, so
|
|
230
|
+
`--limit` bounds the spend; on the free tier the daily request cap does.
|
|
231
|
+
|
|
232
|
+
## 9. Translated, ad-filtered delivery
|
|
233
|
+
|
|
234
|
+
A poll can reshape each notification as it goes out, both opt-in and both running on the
|
|
235
|
+
same LLM backend as `classify`:
|
|
236
|
+
|
|
237
|
+
- `--translate LANGUAGE` renders each post into a language, shown above the original so
|
|
238
|
+
the source stays for reference:
|
|
239
|
+
|
|
240
|
+
```
|
|
241
|
+
@trader
|
|
242
|
+
|
|
243
|
+
시장이 조정 국면에 들어섰습니다. 현금 비중을 높이세요.
|
|
244
|
+
──────────
|
|
245
|
+
The market has entered a correction. Raise cash.
|
|
246
|
+
https://x.com/trader/status/…
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
- `--filter-ads` runs the ad judgment at send time and does not deliver a post it judges
|
|
250
|
+
promotional. The post is still archived and its verdict stored — nothing is lost, the
|
|
251
|
+
notifications are just quieter.
|
|
252
|
+
|
|
253
|
+
Both degrade safely: a translation or classification failure delivers the original post
|
|
254
|
+
rather than dropping it, so an LLM outage never stalls a watch. Turn either on permanently
|
|
255
|
+
for a scheduled poll via config.toml, so a cron `xwatch poll` picks it up with no flags:
|
|
256
|
+
|
|
257
|
+
```toml
|
|
258
|
+
translate = "Korean" # any language name -- "Spanish", "Japanese", ...
|
|
259
|
+
filter_ads = true
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## 10. What each post keeps
|
|
263
|
+
|
|
264
|
+
The archive stores the whole post, not just its visible text: the full body of a long
|
|
265
|
+
"note" tweet (not the truncated preview), a retweet's original text, and the post's media,
|
|
266
|
+
engagement metrics, and entities — cashtags ($MU, $DRAM), hashtags, mentions, and the
|
|
267
|
+
expanded links behind its t.co shorteners. Downloaded image and video-thumbnail files live
|
|
268
|
+
beside the posts under `archive/media/`, keyed so an image shared across a retweet is stored
|
|
269
|
+
once.
|
|
270
|
+
|
|
271
|
+
## 11. How it stays cheap and correct
|
|
272
|
+
|
|
273
|
+
- **`since_id` bounds every fetch.** Only posts newer than the last one seen come
|
|
274
|
+
back; an idle account costs nothing. Dropping replies/retweets at the API keeps
|
|
275
|
+
even a chatty account cheap.
|
|
276
|
+
- **The watermark advances past every fetched post**, even ones the text filter
|
|
277
|
+
drops — so a filtered-out post is never re-fetched, and no post is delivered twice.
|
|
278
|
+
- **The resolved user id is cached** per handle, so a poll never re-pays to look up
|
|
279
|
+
an account it already knows.
|
|
280
|
+
- **Files are split by kind** (the XDG layout): hand-editable config and the token in
|
|
281
|
+
`~/.config/xwatch`, the archive in `~/.local/share/xwatch`, run state in
|
|
282
|
+
`~/.local/state/xwatch`. Resetting settings never touches the archive.
|
|
283
|
+
|
|
284
|
+
## 12. Where things live
|
|
285
|
+
|
|
286
|
+
| Path | What |
|
|
287
|
+
|---|---|
|
|
288
|
+
| `~/.config/xwatch/accounts.toml` | the watched accounts (hand-editable) |
|
|
289
|
+
| `~/.config/xwatch/config.toml` | non-secret settings (`translate`, `filter_ads`, data dir) |
|
|
290
|
+
| `~/.config/xwatch/credentials.json` | the bearer token, and any LLM provider keys (0600) |
|
|
291
|
+
| `~/.local/share/xwatch/archive/posts/` | the collected posts, one JSON file each |
|
|
292
|
+
| `~/.local/share/xwatch/archive/media/` | downloaded image / video-thumbnail files |
|
|
293
|
+
| `~/.local/state/xwatch/state.json` | the per-account since-id watermarks and user-id cache |
|
|
294
|
+
|
|
295
|
+
## 13. License
|
|
296
|
+
|
|
297
|
+
MIT
|