android-watcher 1.0.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.
Files changed (137) hide show
  1. android_watcher-1.0.0/.editorconfig +24 -0
  2. android_watcher-1.0.0/.github/ISSUE_TEMPLATE/bug_report.yml +45 -0
  3. android_watcher-1.0.0/.github/ISSUE_TEMPLATE/config.yml +8 -0
  4. android_watcher-1.0.0/.github/ISSUE_TEMPLATE/feature_request.yml +24 -0
  5. android_watcher-1.0.0/.github/PULL_REQUEST_TEMPLATE.md +14 -0
  6. android_watcher-1.0.0/.github/workflows/ci.yml +50 -0
  7. android_watcher-1.0.0/.github/workflows/release.yml +112 -0
  8. android_watcher-1.0.0/.github/workflows/seed.yml +67 -0
  9. android_watcher-1.0.0/.gitignore +49 -0
  10. android_watcher-1.0.0/.pre-commit-config.yaml +33 -0
  11. android_watcher-1.0.0/CLAUDE.md +158 -0
  12. android_watcher-1.0.0/CODE_OF_CONDUCT.md +41 -0
  13. android_watcher-1.0.0/CONTRIBUTING.md +106 -0
  14. android_watcher-1.0.0/Formula/android-watcher.rb +27 -0
  15. android_watcher-1.0.0/LICENSE +21 -0
  16. android_watcher-1.0.0/PKG-INFO +310 -0
  17. android_watcher-1.0.0/README.md +284 -0
  18. android_watcher-1.0.0/SECURITY.md +36 -0
  19. android_watcher-1.0.0/assets/logo.svg +19 -0
  20. android_watcher-1.0.0/docs/scheduling.md +157 -0
  21. android_watcher-1.0.0/pyproject.toml +80 -0
  22. android_watcher-1.0.0/scripts/build_seed.py +126 -0
  23. android_watcher-1.0.0/scripts/verify_catalog.py +90 -0
  24. android_watcher-1.0.0/src/android_watcher/__init__.py +10 -0
  25. android_watcher-1.0.0/src/android_watcher/catalog/__init__.py +32 -0
  26. android_watcher-1.0.0/src/android_watcher/catalog/catalog.toml +531 -0
  27. android_watcher-1.0.0/src/android_watcher/cli.py +161 -0
  28. android_watcher-1.0.0/src/android_watcher/config.py +262 -0
  29. android_watcher-1.0.0/src/android_watcher/detect/__init__.py +1 -0
  30. android_watcher-1.0.0/src/android_watcher/detect/_normalize.py +192 -0
  31. android_watcher-1.0.0/src/android_watcher/detect/android_sitemap.py +540 -0
  32. android_watcher-1.0.0/src/android_watcher/detect/base.py +14 -0
  33. android_watcher-1.0.0/src/android_watcher/detect/content.py +99 -0
  34. android_watcher-1.0.0/src/android_watcher/detect/feed.py +135 -0
  35. android_watcher-1.0.0/src/android_watcher/detect/sitemap.py +203 -0
  36. android_watcher-1.0.0/src/android_watcher/doctor.py +125 -0
  37. android_watcher-1.0.0/src/android_watcher/fetch.py +162 -0
  38. android_watcher-1.0.0/src/android_watcher/group.py +79 -0
  39. android_watcher-1.0.0/src/android_watcher/lock.py +32 -0
  40. android_watcher-1.0.0/src/android_watcher/models.py +156 -0
  41. android_watcher-1.0.0/src/android_watcher/notify/__init__.py +1 -0
  42. android_watcher-1.0.0/src/android_watcher/notify/base.py +21 -0
  43. android_watcher-1.0.0/src/android_watcher/notify/email.py +52 -0
  44. android_watcher-1.0.0/src/android_watcher/notify/html.py +114 -0
  45. android_watcher-1.0.0/src/android_watcher/notify/render.py +239 -0
  46. android_watcher-1.0.0/src/android_watcher/notify/slack.py +124 -0
  47. android_watcher-1.0.0/src/android_watcher/notify/telegram.py +46 -0
  48. android_watcher-1.0.0/src/android_watcher/rank.py +84 -0
  49. android_watcher-1.0.0/src/android_watcher/registry.py +38 -0
  50. android_watcher-1.0.0/src/android_watcher/run.py +283 -0
  51. android_watcher-1.0.0/src/android_watcher/schedule.py +488 -0
  52. android_watcher-1.0.0/src/android_watcher/seed/__init__.py +45 -0
  53. android_watcher-1.0.0/src/android_watcher/seed/seed.sql.gz +0 -0
  54. android_watcher-1.0.0/src/android_watcher/store.py +492 -0
  55. android_watcher-1.0.0/src/android_watcher/triage/__init__.py +1 -0
  56. android_watcher-1.0.0/src/android_watcher/triage/base.py +25 -0
  57. android_watcher-1.0.0/src/android_watcher/triage/claude_cli.py +185 -0
  58. android_watcher-1.0.0/src/android_watcher/triage/noop.py +24 -0
  59. android_watcher-1.0.0/src/android_watcher/tui/__init__.py +1 -0
  60. android_watcher-1.0.0/src/android_watcher/tui/app.py +163 -0
  61. android_watcher-1.0.0/src/android_watcher/tui/configio.py +215 -0
  62. android_watcher-1.0.0/src/android_watcher/tui/screens.py +927 -0
  63. android_watcher-1.0.0/tests/conftest.py +16 -0
  64. android_watcher-1.0.0/tests/detect/__init__.py +0 -0
  65. android_watcher-1.0.0/tests/detect/test_android_sitemap.py +721 -0
  66. android_watcher-1.0.0/tests/detect/test_base.py +32 -0
  67. android_watcher-1.0.0/tests/detect/test_confirm_shared.py +129 -0
  68. android_watcher-1.0.0/tests/detect/test_content.py +194 -0
  69. android_watcher-1.0.0/tests/detect/test_feed.py +138 -0
  70. android_watcher-1.0.0/tests/detect/test_normalize.py +81 -0
  71. android_watcher-1.0.0/tests/detect/test_registry_autoload.py +23 -0
  72. android_watcher-1.0.0/tests/detect/test_registry_resolution.py +24 -0
  73. android_watcher-1.0.0/tests/detect/test_sitemap.py +356 -0
  74. android_watcher-1.0.0/tests/fixtures/claude_cli_envelope.json +62 -0
  75. android_watcher-1.0.0/tests/fixtures/content_after_chrome_only.html +11 -0
  76. android_watcher-1.0.0/tests/fixtures/content_after_real_change.html +11 -0
  77. android_watcher-1.0.0/tests/fixtures/content_before.html +11 -0
  78. android_watcher-1.0.0/tests/fixtures/content_js_shell.html +7 -0
  79. android_watcher-1.0.0/tests/fixtures/feed_guid_reuse.xml +12 -0
  80. android_watcher-1.0.0/tests/fixtures/feed_initial.xml +18 -0
  81. android_watcher-1.0.0/tests/fixtures/feed_updated_summary.xml +18 -0
  82. android_watcher-1.0.0/tests/fixtures/schedule/android-watcher.service +6 -0
  83. android_watcher-1.0.0/tests/fixtures/schedule/android-watcher.timer +9 -0
  84. android_watcher-1.0.0/tests/fixtures/schedule/launchd_daily.plist +22 -0
  85. android_watcher-1.0.0/tests/fixtures/sitemap_index.xml +9 -0
  86. android_watcher-1.0.0/tests/fixtures/sitemap_shard0.xml +11 -0
  87. android_watcher-1.0.0/tests/fixtures/sitemap_shard_i18n.xml +19 -0
  88. android_watcher-1.0.0/tests/fixtures/sitemap_simple.xml +11 -0
  89. android_watcher-1.0.0/tests/notify/__init__.py +0 -0
  90. android_watcher-1.0.0/tests/notify/snapshots/normal_email.html +7 -0
  91. android_watcher-1.0.0/tests/notify/snapshots/normal_email.txt +4 -0
  92. android_watcher-1.0.0/tests/notify/snapshots/normal_slack.json +25 -0
  93. android_watcher-1.0.0/tests/notify/test_base.py +35 -0
  94. android_watcher-1.0.0/tests/notify/test_email.py +270 -0
  95. android_watcher-1.0.0/tests/notify/test_html.py +113 -0
  96. android_watcher-1.0.0/tests/notify/test_render.py +348 -0
  97. android_watcher-1.0.0/tests/notify/test_slack.py +415 -0
  98. android_watcher-1.0.0/tests/notify/test_telegram.py +434 -0
  99. android_watcher-1.0.0/tests/run/__init__.py +0 -0
  100. android_watcher-1.0.0/tests/run/test_detect_and_persist.py +147 -0
  101. android_watcher-1.0.0/tests/run/test_run_once.py +650 -0
  102. android_watcher-1.0.0/tests/test_catalog.py +28 -0
  103. android_watcher-1.0.0/tests/test_catalog_data.py +71 -0
  104. android_watcher-1.0.0/tests/test_cli.py +280 -0
  105. android_watcher-1.0.0/tests/test_cli_schedule.py +119 -0
  106. android_watcher-1.0.0/tests/test_config.py +222 -0
  107. android_watcher-1.0.0/tests/test_configio.py +457 -0
  108. android_watcher-1.0.0/tests/test_configio_custom_source_roundtrip.py +81 -0
  109. android_watcher-1.0.0/tests/test_contributing_docs.py +24 -0
  110. android_watcher-1.0.0/tests/test_docs_scheduling.py +26 -0
  111. android_watcher-1.0.0/tests/test_doctor.py +287 -0
  112. android_watcher-1.0.0/tests/test_fetch.py +162 -0
  113. android_watcher-1.0.0/tests/test_group.py +121 -0
  114. android_watcher-1.0.0/tests/test_homebrew_formula.py +26 -0
  115. android_watcher-1.0.0/tests/test_lock.py +24 -0
  116. android_watcher-1.0.0/tests/test_models.py +116 -0
  117. android_watcher-1.0.0/tests/test_packaging.py +125 -0
  118. android_watcher-1.0.0/tests/test_rank.py +226 -0
  119. android_watcher-1.0.0/tests/test_readme_disclosures.py +28 -0
  120. android_watcher-1.0.0/tests/test_registry.py +57 -0
  121. android_watcher-1.0.0/tests/test_release_workflow.py +50 -0
  122. android_watcher-1.0.0/tests/test_schedule_crontab.py +53 -0
  123. android_watcher-1.0.0/tests/test_schedule_install.py +276 -0
  124. android_watcher-1.0.0/tests/test_schedule_plist.py +82 -0
  125. android_watcher-1.0.0/tests/test_schedule_status.py +134 -0
  126. android_watcher-1.0.0/tests/test_schedule_systemd.py +61 -0
  127. android_watcher-1.0.0/tests/test_seed.py +178 -0
  128. android_watcher-1.0.0/tests/test_seed_workflow.py +42 -0
  129. android_watcher-1.0.0/tests/test_store.py +385 -0
  130. android_watcher-1.0.0/tests/test_tui_smoke.py +321 -0
  131. android_watcher-1.0.0/tests/test_verify_catalog_smoke.py +28 -0
  132. android_watcher-1.0.0/tests/triage/__init__.py +0 -0
  133. android_watcher-1.0.0/tests/triage/test_base.py +55 -0
  134. android_watcher-1.0.0/tests/triage/test_claude_cli.py +307 -0
  135. android_watcher-1.0.0/tests/triage/test_claude_cli_prompt.py +154 -0
  136. android_watcher-1.0.0/tests/triage/test_noop.py +60 -0
  137. android_watcher-1.0.0/uv.lock +467 -0
@@ -0,0 +1,24 @@
1
+ root = true
2
+
3
+ # Tabs, width 4, everywhere by default.
4
+ [*]
5
+ charset = utf-8
6
+ end_of_line = lf
7
+ insert_final_newline = true
8
+ trim_trailing_whitespace = true
9
+ indent_style = tab
10
+ indent_size = 4
11
+ tab_width = 4
12
+
13
+ # Python line length matches [tool.ruff]; indentation is tabs (ruff format).
14
+ [*.py]
15
+ max_line_length = 100
16
+
17
+ # YAML forbids tab indentation (spec), so it must use spaces.
18
+ [*.{yml,yaml}]
19
+ indent_style = space
20
+ indent_size = 2
21
+
22
+ # Markdown keeps trailing whitespace (two trailing spaces = hard line break).
23
+ [*.md]
24
+ trim_trailing_whitespace = false
@@ -0,0 +1,45 @@
1
+ name: Bug report
2
+ description: Something isn't working as documented
3
+ labels: [ "bug" ]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: Thanks for the report. Please REDACT secrets (SMTP password, Slack bot token, Telegram bot token) and private paths before pasting.
8
+ - type: textarea
9
+ id: what-happened
10
+ attributes:
11
+ label: What happened?
12
+ description: What went wrong, and what did you expect instead?
13
+ validations:
14
+ required: true
15
+ - type: textarea
16
+ id: repro
17
+ attributes:
18
+ label: Steps to reproduce
19
+ placeholder: |
20
+ 1. ...
21
+ 2. ...
22
+ 3. ...
23
+ validations:
24
+ required: true
25
+ - type: input
26
+ id: version
27
+ attributes:
28
+ label: android-watcher version or commit
29
+ description: "`android-watcher --version` or the installed tag."
30
+ validations:
31
+ required: false
32
+ - type: textarea
33
+ id: env
34
+ attributes:
35
+ label: Environment
36
+ description: Install method (uv tool / pipx / Homebrew / source), OS, Python version, AI mode (claude_cli / off).
37
+ validations:
38
+ required: false
39
+ - type: textarea
40
+ id: logs
41
+ attributes:
42
+ label: Logs / output
43
+ description: Relevant output from `android-watcher doctor` or the failing run, with secrets redacted.
44
+ validations:
45
+ required: false
@@ -0,0 +1,8 @@
1
+ blank_issues_enabled: false
2
+ contact_links:
3
+ - name: Question / contact
4
+ url: https://github.com/krayong/android-watcher#contact
5
+ about: General questions that aren't bugs or feature requests (email androidwatcher@krayong.com).
6
+ - name: Security vulnerability
7
+ url: https://github.com/krayong/android-watcher/security/advisories/new
8
+ about: Report security issues privately, not as a public issue (see SECURITY.md).
@@ -0,0 +1,24 @@
1
+ name: Feature request
2
+ description: Suggest an improvement or a new source/channel
3
+ labels: [ "enhancement" ]
4
+ body:
5
+ - type: textarea
6
+ id: problem
7
+ attributes:
8
+ label: Problem
9
+ description: What are you trying to do, and what's missing or awkward today?
10
+ validations:
11
+ required: true
12
+ - type: textarea
13
+ id: proposal
14
+ attributes:
15
+ label: Proposed solution
16
+ description: What you'd like to see. For a new catalog source, link the official page and note its type (RSS feed, sitemap, or content page).
17
+ validations:
18
+ required: false
19
+ - type: textarea
20
+ id: alternatives
21
+ attributes:
22
+ label: Alternatives considered
23
+ validations:
24
+ required: false
@@ -0,0 +1,14 @@
1
+ ## Summary
2
+
3
+ <!-- What does this PR do and why? -->
4
+
5
+ ## Testing
6
+
7
+ <!-- Describe how you verified the change. Note: tests run with `uv run pytest` (no live network; recorded fixtures only). -->
8
+
9
+ ## Checklist
10
+
11
+ - [ ] Tests added or updated for the change
12
+ - [ ] `uv run ruff check .` and `uv run ruff format .` pass with no errors
13
+ - [ ] Comments/docs state facts directly, with no pointers to scratch/planning notes
14
+ - [ ] Follows the guidelines in [CONTRIBUTING.md](../CONTRIBUTING.md)
@@ -0,0 +1,50 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [ main ]
6
+ pull_request:
7
+ branches: [ main ]
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ lint-and-test:
14
+ runs-on: ubuntu-latest
15
+
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - name: Install uv
20
+ uses: astral-sh/setup-uv@v3
21
+
22
+ - name: Set up Python
23
+ run: uv python install 3.11
24
+
25
+ - name: Sync dependencies
26
+ run: uv sync --frozen
27
+
28
+ - name: Ruff lint
29
+ run: uv run ruff check .
30
+
31
+ - name: Ruff format check
32
+ run: uv run ruff format --check .
33
+
34
+ - name: Pytest
35
+ run: uv run pytest -q
36
+
37
+ secret-scan:
38
+ runs-on: ubuntu-latest
39
+ steps:
40
+ - uses: actions/checkout@v4
41
+
42
+ # Filesystem scan (not a git BASE..HEAD diff): the diff mode errors when
43
+ # BASE == HEAD, which happens on a single-commit push to main.
44
+ - name: Install TruffleHog
45
+ run: |
46
+ curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh \
47
+ | sh -s -- -b /usr/local/bin v3.88.1
48
+
49
+ - name: Scan for verified secrets
50
+ run: trufflehog filesystem . --only-verified --fail --no-update
@@ -0,0 +1,112 @@
1
+ name: release
2
+
3
+ # Manual, bump-driven release. Pick the semver part to bump; the job computes the
4
+ # new version from the current one, commits it to main, publishes to PyPI, refreshes
5
+ # the Homebrew formula, and tags the release last.
6
+ on:
7
+ workflow_dispatch:
8
+ inputs:
9
+ bump:
10
+ description: "Semver part to bump (relative to the current version)"
11
+ required: true
12
+ type: choice
13
+ options:
14
+ - patch
15
+ - minor
16
+ - major
17
+
18
+ permissions:
19
+ contents: write # push the bump commit, the formula commit, and the tag
20
+
21
+ concurrency:
22
+ group: release
23
+ cancel-in-progress: false
24
+
25
+ jobs:
26
+ release:
27
+ runs-on: ubuntu-latest
28
+
29
+ environment: prod # holds the PYPI_TOKEN secret
30
+
31
+ steps:
32
+ - uses: actions/checkout@v4
33
+ with:
34
+ ref: main
35
+ fetch-depth: 0 # full history so we can push back and tag
36
+
37
+ - name: Configure git as the release bot
38
+ run: |
39
+ set -euo pipefail
40
+ git config user.name "github-actions[bot]"
41
+ git config user.email "github-actions[bot]@users.noreply.github.com"
42
+ git fetch --tags --force
43
+
44
+ - name: Install uv
45
+ uses: astral-sh/setup-uv@v5
46
+
47
+ - name: Set up Python
48
+ run: uv python install 3.11
49
+
50
+ - name: Bump version
51
+ id: bump
52
+ run: |
53
+ uv version --bump "${{ inputs.bump }}"
54
+ NEW=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
55
+ echo "version=$NEW" >> "$GITHUB_OUTPUT"
56
+ echo "Bumped ${{ inputs.bump }} -> $NEW"
57
+
58
+ - name: Run tests (no live network)
59
+ run: uv run pytest
60
+
61
+ - name: Build sdist and wheel
62
+ run: uv build
63
+
64
+ - name: Commit version bump to main
65
+ run: |
66
+ git add pyproject.toml uv.lock
67
+ git commit -m "chore: bump version to v${{ steps.bump.outputs.version }}"
68
+ git push origin HEAD:main
69
+
70
+ - name: Publish to PyPI
71
+ uses: pypa/gh-action-pypi-publish@release/v1
72
+ with:
73
+ password: ${{ secrets.PYPI_TOKEN }}
74
+
75
+ - name: Update Homebrew formula
76
+ run: |
77
+ VERSION="${{ steps.bump.outputs.version }}"
78
+ # PyPI's JSON index lags publish by a few seconds; poll until the release shows up.
79
+ for i in $(seq 1 30); do
80
+ if curl -fsSL "https://pypi.org/pypi/android-watcher/${VERSION}/json" -o /tmp/pypi.json; then
81
+ break
82
+ fi
83
+ echo "Waiting for PyPI to index ${VERSION} (attempt ${i})…"
84
+ sleep 10
85
+ done
86
+ read -r SDIST_URL SDIST_SHA < <(python3 - <<'PY'
87
+ import json
88
+ d = json.load(open("/tmp/pypi.json"))
89
+ s = next(u for u in d["urls"] if u["packagetype"] == "sdist")
90
+ print(s["url"], s["digests"]["sha256"])
91
+ PY
92
+ )
93
+ # Point the formula at the freshly published sdist + its hash.
94
+ python3 - "$SDIST_URL" "$SDIST_SHA" <<'PY'
95
+ import re, sys
96
+ url, sha = sys.argv[1], sys.argv[2]
97
+ p = "Formula/android-watcher.rb"
98
+ s = open(p).read()
99
+ s = re.sub(r' url ".*"', f' url "{url}"', s, count=1)
100
+ s = re.sub(r' sha256 ".*"', f' sha256 "{sha}"', s, count=1)
101
+ open(p, "w").write(s)
102
+ PY
103
+ # Regenerate every pinned dependency `resource` block against this release.
104
+ brew update-python-resources Formula/android-watcher.rb
105
+ git add Formula/android-watcher.rb
106
+ git commit -m "chore: update homebrew formula for v${VERSION}"
107
+ git push origin HEAD:main
108
+
109
+ - name: Tag the release
110
+ run: |
111
+ git tag "v${{ steps.bump.outputs.version }}"
112
+ git push origin "v${{ steps.bump.outputs.version }}"
@@ -0,0 +1,67 @@
1
+ name: seed
2
+
3
+ # Regenerate the bundled baseline seed (src/android_watcher/seed/seed.sql.gz).
4
+ # This runs the full, polite, hours-long crawl of the catalog sources, so it is
5
+ # DELIBERATELY kept off the release path (release.yml just bundles whatever seed
6
+ # is committed). Trigger it manually from the Actions tab, or let the weekly
7
+ # schedule refresh it. You can also generate it locally and push:
8
+ # uv run python scripts/build_seed.py
9
+ on:
10
+ workflow_dispatch:
11
+
12
+ schedule:
13
+ - cron: "30 23 * * 0" # Mondays 05:00 IST (Sun 23:30 UTC)
14
+
15
+ permissions:
16
+ contents: write # commit + push the refreshed seed
17
+
18
+ concurrency:
19
+ group: seed
20
+ cancel-in-progress: false
21
+
22
+ jobs:
23
+ build-seed:
24
+ runs-on: ubuntu-latest
25
+
26
+ # The crawl is throttled by a per-host crawl delay; bound it well under the
27
+ # 6h hard limit. The WIP-DB cache lets a re-run resume an interrupted crawl.
28
+ timeout-minutes: 350
29
+
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+ with:
33
+ ref: main
34
+
35
+ - name: Install uv
36
+ uses: astral-sh/setup-uv@v5
37
+
38
+ - name: Set up Python
39
+ run: uv python install 3.11
40
+
41
+ - name: Sync dependencies
42
+ run: uv sync --frozen
43
+
44
+ # Persist the partial crawl DB so a retried run continues instead of
45
+ # restarting (baseline_all skips URLs already baselined).
46
+ - name: Restore crawl progress
47
+ uses: actions/cache@v4
48
+ with:
49
+ path: .seed-wip.db
50
+ key: seed-wip-${{ github.run_id }}
51
+ restore-keys: seed-wip-
52
+
53
+ - name: Build the baseline seed
54
+ run: uv run python scripts/build_seed.py --db .seed-wip.db
55
+
56
+ - name: Commit refreshed seed
57
+ run: |
58
+ set -euo pipefail
59
+ git config user.name "github-actions[bot]"
60
+ git config user.email "github-actions[bot]@users.noreply.github.com"
61
+ git add src/android_watcher/seed/seed.sql.gz
62
+ if git diff --staged --quiet; then
63
+ echo "seed unchanged; nothing to commit"
64
+ else
65
+ git commit -m "chore: refresh baseline seed"
66
+ git push origin HEAD:main
67
+ fi
@@ -0,0 +1,49 @@
1
+ # Project-specific
2
+ .idea/
3
+
4
+ # Python bytecode
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+
9
+ # Packaging / build
10
+ build/
11
+ dist/
12
+ *.egg
13
+ *.egg-info/
14
+ .eggs/
15
+ wheels/
16
+
17
+ # Virtual environments
18
+ .venv/
19
+ venv/
20
+ env/
21
+ ENV/
22
+ # NOTE: uv.lock IS committed (pinned deps); do not ignore it.
23
+
24
+ # Test / coverage / type / lint caches
25
+ .pytest_cache/
26
+ .ruff_cache/
27
+ .mypy_cache/
28
+ .pyright/
29
+ .coverage
30
+ .coverage.*
31
+ coverage.xml
32
+ htmlcov/
33
+ .tox/
34
+ .nox/
35
+
36
+ # Local env and secrets
37
+ .env
38
+ .env.*
39
+
40
+ # Editors
41
+ .vscode/
42
+ *.swp
43
+
44
+ # OS cruft
45
+ .DS_Store
46
+ Thumbs.db
47
+
48
+ # Planning and design docs (local-only working scratch, not shipped)
49
+ docs/superpowers/
@@ -0,0 +1,33 @@
1
+ # Local lint + format + hygiene enforcement.
2
+ # Install once with: uv run pre-commit install
3
+ # Run on all files with: uv run pre-commit run --all-files
4
+ # The ruff version here is kept in sync with the pinned dev dependency.
5
+ repos:
6
+ - repo: https://github.com/astral-sh/ruff-pre-commit
7
+ rev: v0.15.18
8
+ hooks:
9
+ - id: ruff
10
+ name: ruff (lint)
11
+ args: [ --fix ]
12
+ - id: ruff-format
13
+ name: ruff (format)
14
+
15
+ - repo: https://github.com/pre-commit/pre-commit-hooks
16
+ rev: v5.0.0
17
+ hooks:
18
+ - id: trailing-whitespace
19
+ args: [ --markdown-linebreak-ext=md ]
20
+ - id: end-of-file-fixer
21
+ - id: check-json
22
+ - id: check-yaml
23
+ - id: check-merge-conflict
24
+ - id: check-added-large-files
25
+
26
+ - repo: https://github.com/trufflesecurity/trufflehog
27
+ rev: v3.88.1
28
+ hooks:
29
+ - id: trufflehog
30
+ name: Scan for secrets
31
+ entry: trufflehog git file://. --since-commit HEAD --fail
32
+ language: golang
33
+ pass_filenames: false
@@ -0,0 +1,158 @@
1
+ # CLAUDE.md
2
+
3
+ Implementation guide for `android-watcher`. The rules here are the durable source
4
+ of truth. Build to these, not to memory.
5
+
6
+ ## What this is
7
+
8
+ A self-hosted Python CLI that watches official Google Android sites, detects
9
+ real (not cosmetic) changes on a schedule, uses Claude to triage and describe
10
+ them, ranks the result, and delivers a digest to email or Slack. Single user per
11
+ install. Configured through a Textual TUI that writes a TOML file and installs a
12
+ native scheduled job.
13
+
14
+ ## Commands
15
+
16
+ ```bash
17
+ uv sync # install deps from the lockfile
18
+ uv run pytest # run tests (no live network; recorded fixtures only)
19
+ uv run pytest path::test -v # one test
20
+ uv run ruff check . # lint
21
+ uv run ruff format . # format
22
+ uv tool install . # install the CLI locally
23
+ ```
24
+
25
+ ## Package layout
26
+
27
+ ```
28
+ src/android_watcher/
29
+ models.py # dataclasses, exceptions, Check, SignalType, INTERVAL_DELTA
30
+ config.py # Config + load_config (env interpolation, validation)
31
+ catalog/ # catalog.toml (shipped data) + load_catalog
32
+ store.py # SQLite: snapshots, changes, deliveries, digests, seen_feed_items, http_cache, run_state; seed import/export
33
+ seed/ # bundled baseline seed (seed.sql.gz) + apply_seed_if_empty
34
+ lock.py # single-instance run lock
35
+ fetch.py # async Fetcher: conditional GET, robots, backoff, sitemap cache
36
+ registry.py # generic name->class registry
37
+ detect/ # base + feed, android_sitemap, sitemap, content
38
+ rank.py # scoring, per-source caps, overflow
39
+ triage/ # base + claude_cli, noop
40
+ notify/ # base + render, email, slack
41
+ schedule.py # launchd / systemd / crontab install/remove/status
42
+ doctor.py # health checks
43
+ run.py # run_once pipeline
44
+ cli.py # `android-watcher` entrypoint
45
+ tui/ # Textual app + pure config<->TOML module
46
+ tests/ # mirrors src; tests/fixtures/ for recorded data
47
+ ```
48
+
49
+ ## Durable invariants
50
+
51
+ These are the rules that make the tool correct. They were hard-won; do not relax
52
+ them without understanding why they exist.
53
+
54
+ ### Detection: candidate then confirm
55
+
56
+ - A feed item, a sitemap `lastmod` bump, or a content-hash change is a *candidate*, not a change. Confirm it against the
57
+ actual fetched page content before recording a change. `lastmod` alone never counts: Google bumps it on bulk
58
+ regenerations, template edits, and translation passes.
59
+ - A detector never emits a `Change` for a health problem (an empty/JS-only render, a path prefix that matches zero
60
+ sitemap URLs). It logs a warning and returns nothing; `doctor` surfaces the condition.
61
+ - The content detector refuses to baseline a page whose extracted text is below a small threshold (a client-rendered
62
+ shell), so it never silently hashes nothing forever.
63
+ - Feed dedupe keys on a stable identity: the Atom `<id>` verbatim, else a normalized link URL. Persist the full
64
+ seen-set. An existing item counts as changed only when its title+summary hash moves.
65
+ - The `android_sitemap` detector is host-agnostic: it parses a host's sitemap (a `<sitemapindex>` of shards, or a single
66
+ `<urlset>`) once per run, cached on the `Fetcher` keyed by the sitemap-index URL derived from each source's host (
67
+ `<scheme>://<host>/sitemap.xml`). Sources on the same host share one download (guarded by an `asyncio.Lock`,
68
+ conditional GET per shard); different hosts each get their own. It serves developer.android.com, source.android.com,
69
+ developers.google.com, kotlinlang.org, etc. — never download a host's shards per source.
70
+ - English only: locale-prefixed URLs (`/fr/...`, `/pt-br/...`) and `?hl=<non-en>` query variants are dropped at parse
71
+ time, so only canonical English pages are watched. Match the leading path segment against an explicit locale
72
+ allowlist, never a generic two-letter regex. Real sections like `/tv`, `/xr`, `/ai` start with two letters and must
73
+ not be mistaken for locales.
74
+ - Per-source filters (applied against the shared cached entry list): `path_prefix` (include; `""` = whole host),
75
+ `exclude_prefixes` (drop subtrees), `require_segment` (keep only URLs with a matching path segment, e.g. `android`),
76
+ and `reference_mode` (`keep` | `drop` | `index_only`). `index_only` keeps only reference index/summary pages — leaf in
77
+ `{package-summary, packages, classes, composables, modifiers}`, Kotlin-preferred (a Java reference page is dropped
78
+ when its `/reference/kotlin/...` twin exists), so the huge per-symbol class/function reference is excluded.
79
+ Most-specific-prefix-wins (scoped per host) routes a URL to its nested source; a `""` catch-all coexists with curated
80
+ sources for ranking weights.
81
+ - Version-dedup: URLs differing only by a dotted version segment (`9.4`) or a `?version=`/`?api=` query collapse to the
82
+ latest. Bare-integer paths (`/about/versions/14` vs `/15`) are untouched: those are distinct releases, not versions of
83
+ one page.
84
+ - Fetch-free first sight, then new-page detection: when a source has no baseline yet (first run / seed import), a
85
+ brand-new URL is baselined from its `lastmod` alone (empty `content_hash`, no fetch), with no "everything is new"
86
+ flood. Once a baseline exists, a never-seen URL is content-confirmed and reported as `Change(change_kind="new")`. An
87
+ already-baselined URL whose `lastmod` moves is content-confirmed; the first real fetch of a fetch-free baseline is
88
+ itself a silent capture (`confirm_candidate` treats an empty prior `content_hash` as first sight). `lastmod` alone
89
+ never emits a `Change`.
90
+ - All XML parsing uses `defusedxml`. Honor `robots.txt`. Send a descriptive User-Agent and a crawl delay.
91
+
92
+ ### Pipeline: the delivery ledger
93
+
94
+ - `run_once` holds a single-instance lock. Overlapping runs exit immediately.
95
+ - The authoritative digest source is the ledger, never this-run detections. Rank `changes_for_digest(enabled_channels)`:
96
+ substantive changes not yet delivered to every enabled channel, at most one row per `(source_id, url)` (latest by
97
+ `detected_at`).
98
+ - `record_change` is idempotent on `(source_id, url, fetched_hash)`: it returns the existing row id and never resets a
99
+ verdict.
100
+ - `set_verdict` is write-once. Triage only touches rows with `verdict IS NULL`.
101
+ - When a ranked change is delivered, `supersede_older` marks older undelivered rows for the same `(source_id, url)` so a
102
+ page that changed twice yields one digest line, not a stale one.
103
+ - Delivery is per `(change, channel)`, recorded in `deliveries`. Send, then record the delivery transactionally. A
104
+ channel that already succeeded is never re-sent; a channel that failed is retried next run.
105
+ - An in-flight `digests` row is opened before sending and reconciled on the next startup: re-deliver the undelivered
106
+ channels, then commit. This closes the crash-between-send-and-commit window.
107
+ - First run baselines silently (no "everything is new" flood); the first digest after baseline is capped.
108
+ - A fresh DB imports the bundled baseline seed (`seed/seed.sql.gz`) before detecting: `apply_seed_if_empty` loads it
109
+ only when `snapshot_count() == 0`, via `INSERT OR IGNORE` so user data is never overwritten. The seed carries
110
+ snapshots + feed seen-set + HTTP validators, tagged with a `seed_date` in `run_state` (never `last_successful_run`).
111
+ The seed is generated by `scripts/build_seed.py` (the one expensive full-content crawl — run locally or via the `seed`
112
+ workflow, never in `release.yml`) and committed; the build bundles it via the wheel `artifacts` glob. When absent,
113
+ import is a no-op and the detectors baseline fetch-free instead. `doctor` surfaces the seed date and snapshot count.
114
+ - An empty "nothing notable" digest is sent at most once per catch-up window.
115
+ - Zero channels enabled: short-circuit before opening a digest, still mark the run successful.
116
+ - A missed cycle (machine asleep) is backfilled via `last_successful_run` and `INTERVAL_DELTA`, not dropped.
117
+
118
+ ### AI / triage
119
+
120
+ - `claude_cli` shells out to `claude -p --output-format json`, strips a markdown code fence from the result before
121
+ parsing, and on any failure returns `TriageResult(unavailable=<reason>)` without raising. The digest still goes out,
122
+ with a visible "AI unavailable" banner.
123
+ - Fetched page content is untrusted. Wrap it in per-run nonce-fenced blocks, length-cap it, and instruct the model to
124
+ treat it as data, never instructions.
125
+ - `noop` (AI off) marks every change substantive with no description and does not filter.
126
+
127
+ ### Config and secrets
128
+
129
+ - Paths come from `platformdirs`. The config file is written `0600`.
130
+ - String values support `${ENV_VAR}` interpolation on secret-bearing fields, resolved at load. The TUI editor loads with
131
+ `expand=False` so it preserves `${...}` literals and never crashes on an unset variable.
132
+ - Source selection: start from catalog entries with `enabled=True`; if the user's `enabled_sources` list is non-empty,
133
+ intersect with it; an empty or absent list means "use the catalog flags," not "none." Custom sources are always
134
+ watched; on id collision a custom source overrides the catalog. The TUI writes the reserved id `["__none__"]` to
135
+ mean "watch no catalog sources."
136
+ - SMTP enforces TLS and fails closed. The Slack bot token is a secret.
137
+
138
+ ### Conventions
139
+
140
+ - Python 3.11+. Async lives only in `fetch.py` and detectors; everything else is synchronous. `run_once` drives the
141
+ async detectors with `asyncio.run`.
142
+ - Always `datetime.now(timezone.utc)`; never the naive `utcnow()`. The store coerces to UTC-aware at its boundary.
143
+ - Shared types, the four exceptions (`ConfigError`, `AlreadyRunning`, `Disallowed`, `NotifyError`), `Check`,
144
+ `SignalType`, and `INTERVAL_DELTA` are defined once in `models.py` and imported everywhere, which keeps the import
145
+ graph acyclic.
146
+ - Registries store classes. `get(name)` returns the class; callers instantiate it with no arguments. Unknown names raise
147
+ with a message listing the available ones.
148
+ - TDD: write the failing test first with real assertions, make it pass with the minimal real code, commit. No live
149
+ network in tests; use recorded fixtures under `tests/fixtures/`. A test must exercise the guarantee, not echo the
150
+ implementation.
151
+
152
+ ## No Stale Plan References
153
+
154
+ **IMPORTANT:** After any refactor or new feature, do NOT leave references in code or markdown to what the plan
155
+ was — no "per the plan", no "according to decision N", no plan phase or task numbers, no pointer to the
156
+ planning or design doc. Those planning docs are scratch that won't survive, so a citation to them becomes a
157
+ stale, dangling reference the moment they're deleted. State the fact or rule directly; if a decision's
158
+ rationale matters, write the rationale itself, not a pointer to where it was decided.
@@ -0,0 +1,41 @@
1
+ # Code of Conduct
2
+
3
+ ## Our pledge
4
+
5
+ This project adopts the [Contributor Covenant](https://www.contributor-covenant.org),
6
+ version 2.1, as its Code of Conduct. We are committed to providing a welcoming,
7
+ respectful, and harassment-free experience for everyone who participates in this
8
+ project, regardless of background or identity.
9
+
10
+ The full text of the Contributor Covenant v2.1 is available at:
11
+ https://www.contributor-covenant.org/version/2/1/code_of_conduct/
12
+
13
+ By participating in this project, you agree to uphold its standards: be
14
+ considerate and respectful in all interactions, welcome differing viewpoints,
15
+ accept constructive feedback gracefully, and focus on what is best for the
16
+ community.
17
+
18
+ ## Scope
19
+
20
+ This Code of Conduct applies within all project spaces (the repository, issue
21
+ tracker, pull requests, and discussions) and when an individual represents the
22
+ project in public spaces.
23
+
24
+ ## Reporting
25
+
26
+ If you experience or witness unacceptable behavior, report it confidentially to
27
+ the project maintainers by opening a private/security advisory on the project's
28
+ GitHub repository, or by emailing **androidwatcher@krayong.com**. All reports are
29
+ reviewed and investigated promptly and fairly. Maintainers are obligated to
30
+ respect the privacy and security of anyone who reports an incident.
31
+
32
+ ## Enforcement
33
+
34
+ Maintainers are responsible for clarifying and enforcing these standards and may
35
+ take appropriate, fair corrective action in response to behavior they deem
36
+ inappropriate, threatening, offensive, or harmful, including removing comments,
37
+ commits, code, and contributions, or temporarily or permanently banning a
38
+ contributor for behaviors they judge to violate this Code of Conduct.
39
+
40
+ Enforcement follows the Contributor Covenant v2.1 enforcement guidelines, linked
41
+ above.