feedscope 0.1.0__tar.gz → 0.2.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.
- {feedscope-0.1.0 → feedscope-0.2.0}/.github/workflows/ci.yml +7 -7
- {feedscope-0.1.0 → feedscope-0.2.0}/.github/workflows/release.yml +5 -5
- {feedscope-0.1.0 → feedscope-0.2.0}/.gitignore +2 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/CHANGELOG.md +16 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/PKG-INFO +24 -2
- {feedscope-0.1.0 → feedscope-0.2.0}/README.md +23 -1
- {feedscope-0.1.0 → feedscope-0.2.0}/README.zh-CN.md +23 -1
- {feedscope-0.1.0 → feedscope-0.2.0}/SECURITY.md +3 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/scripts/demo_gif.py +11 -1
- {feedscope-0.1.0 → feedscope-0.2.0}/scripts/screenshot.py +32 -1
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/__init__.py +1 -1
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/cli.py +42 -1
- feedscope-0.2.0/src/feedscope/core/feed_cache.py +104 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/core/i18n/en.toml +19 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/core/i18n/zh-CN.toml +19 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/core/models.py +36 -0
- feedscope-0.2.0/src/feedscope/core/store.py +270 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/doctor.py +5 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/sources/rss/parse.py +4 -2
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/sources/telegram/parse.py +6 -4
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/ui/app.py +3 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/ui/layout.py +20 -3
- feedscope-0.2.0/src/feedscope/ui/list_render.py +80 -0
- feedscope-0.2.0/src/feedscope/ui/screens/favorites.py +277 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/ui/screens/main.py +174 -25
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/ui/screens/reading.py +15 -1
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/conftest.py +5 -0
- feedscope-0.2.0/tests/test_aggregate_tag.py +79 -0
- feedscope-0.2.0/tests/test_favorites.py +214 -0
- feedscope-0.2.0/tests/test_feed_cache.py +130 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_i18n_no_hardcoded.py +3 -2
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_language_screen.py +5 -3
- feedscope-0.2.0/tests/test_read_state.py +172 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_rss_parse.py +8 -4
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_rss_source.py +2 -1
- feedscope-0.2.0/tests/test_store.py +230 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_telegram_parse.py +5 -3
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_telegram_source.py +2 -1
- feedscope-0.1.0/src/feedscope/ui/list_render.py +0 -42
- {feedscope-0.1.0 → feedscope-0.2.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/.github/dependabot.yml +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/CODE_OF_CONDUCT.md +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/CONTRIBUTING.md +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/LICENSE +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/docs/release.md +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/pyproject.toml +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/scripts/x_probe.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/__main__.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/core/__init__.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/core/browser.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/core/config.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/core/const.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/core/display.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/core/http.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/core/i18n/__init__.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/core/media.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/core/registry.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/core/samples.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/core/source.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/core/text.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/core/timefmt.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/core/tree.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/login.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/probe.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/report.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/sources/__init__.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/sources/hn/__init__.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/sources/hn/fetch.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/sources/hn/parse.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/sources/reddit/__init__.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/sources/reddit/fetch.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/sources/reddit/parse.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/sources/rss/__init__.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/sources/rss/fetch.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/sources/telegram/__init__.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/sources/telegram/fetch.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/sources/v2ex/__init__.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/sources/v2ex/fetch.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/sources/v2ex/parse.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/sources/x/__init__.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/sources/x/fetch.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/sources/x/parse.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/sources/zhihu/__init__.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/sources/zhihu/fetch.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/sources/zhihu/parse.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/subscriptions.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/ui/__init__.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/ui/account.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/ui/dialogs.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/ui/feedback.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/ui/focus_nav.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/ui/labels.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/ui/screens/__init__.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/ui/screens/answer.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/ui/screens/detail.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/ui/screens/language.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/ui/screens/main_setup.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/ui/screens/source_setup.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/ui/screens/subscriptions.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/ui/theme.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/src/feedscope/ui/wrap.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_account.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_architecture.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_browser.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_channel.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_config.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_display.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_doctor.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_guard.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_hn_parse.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_hn_source.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_http.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_i18n.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_login.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_media.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_parsers.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_probe.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_reddit_parse.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_reddit_source.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_samples.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_scripts_smoke.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_source_caps.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_subs_screen.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_subscriptions.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_text.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_theme.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_timefmt.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_tree.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_tui_smoke.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_v2ex_parse.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_v2ex_source.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_wrap.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_x_fetch.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/tests/test_zhihu_fetch.py +0 -0
- {feedscope-0.1.0 → feedscope-0.2.0}/uv.lock +0 -0
|
@@ -9,8 +9,8 @@ jobs:
|
|
|
9
9
|
lint:
|
|
10
10
|
runs-on: ubuntu-latest
|
|
11
11
|
steps:
|
|
12
|
-
- uses: actions/checkout@
|
|
13
|
-
- uses: astral-sh/setup-uv@
|
|
12
|
+
- uses: actions/checkout@v7
|
|
13
|
+
- uses: astral-sh/setup-uv@v7
|
|
14
14
|
- run: uv sync
|
|
15
15
|
- run: uv run ruff check src/ tests/ scripts/
|
|
16
16
|
- run: uv run mypy
|
|
@@ -26,8 +26,8 @@ jobs:
|
|
|
26
26
|
python-version: ["3.11", "3.12", "3.13"]
|
|
27
27
|
runs-on: ${{ matrix.os }}
|
|
28
28
|
steps:
|
|
29
|
-
- uses: actions/checkout@
|
|
30
|
-
- uses: astral-sh/setup-uv@
|
|
29
|
+
- uses: actions/checkout@v7
|
|
30
|
+
- uses: astral-sh/setup-uv@v7
|
|
31
31
|
with:
|
|
32
32
|
python-version: ${{ matrix.python-version }}
|
|
33
33
|
- run: uv sync --python ${{ matrix.python-version }}
|
|
@@ -40,10 +40,10 @@ jobs:
|
|
|
40
40
|
# 打包配置随时可构建:pyproject 的 packaging 回归在这里红,而不是发版当天才炸
|
|
41
41
|
runs-on: ubuntu-latest
|
|
42
42
|
steps:
|
|
43
|
-
- uses: actions/checkout@
|
|
44
|
-
- uses: astral-sh/setup-uv@
|
|
43
|
+
- uses: actions/checkout@v7
|
|
44
|
+
- uses: astral-sh/setup-uv@v7
|
|
45
45
|
- run: uv build
|
|
46
|
-
- uses: actions/upload-artifact@
|
|
46
|
+
- uses: actions/upload-artifact@v7
|
|
47
47
|
with:
|
|
48
48
|
name: dist-preview
|
|
49
49
|
path: dist/
|
|
@@ -16,10 +16,10 @@ jobs:
|
|
|
16
16
|
build:
|
|
17
17
|
runs-on: ubuntu-latest
|
|
18
18
|
steps:
|
|
19
|
-
- uses: actions/checkout@
|
|
20
|
-
- uses: astral-sh/setup-uv@
|
|
19
|
+
- uses: actions/checkout@v7
|
|
20
|
+
- uses: astral-sh/setup-uv@v7
|
|
21
21
|
- run: uv build # sdist + wheel; version is read from src/feedscope/__init__.py
|
|
22
|
-
- uses: actions/upload-artifact@
|
|
22
|
+
- uses: actions/upload-artifact@v7
|
|
23
23
|
with:
|
|
24
24
|
name: dist
|
|
25
25
|
path: dist/
|
|
@@ -32,7 +32,7 @@ jobs:
|
|
|
32
32
|
permissions:
|
|
33
33
|
id-token: write # OIDC trusted publishing — no PyPI token stored anywhere
|
|
34
34
|
steps:
|
|
35
|
-
- uses: actions/download-artifact@
|
|
35
|
+
- uses: actions/download-artifact@v8
|
|
36
36
|
with:
|
|
37
37
|
name: dist
|
|
38
38
|
path: dist/
|
|
@@ -44,7 +44,7 @@ jobs:
|
|
|
44
44
|
permissions:
|
|
45
45
|
contents: write
|
|
46
46
|
steps:
|
|
47
|
-
- uses: actions/download-artifact@
|
|
47
|
+
- uses: actions/download-artifact@v8
|
|
48
48
|
with:
|
|
49
49
|
name: dist
|
|
50
50
|
path: dist/
|
|
@@ -5,6 +5,21 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.2.0] - 2026-11-08
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Read/unread state: the row-leading `●` marks an item unread and clears when you open it (Enter into detail, `Ctrl+O` to the browser, `←`/`→` article stepping in a detail); `Ctrl+R` toggles the current item; unread persists across refreshes and restarts in a plain-text `state.json` (item ids and timestamps only, no credentials, 10k-entry FIFO cap), while the "N new" count of first-seen items shows in the status line
|
|
15
|
+
- Favorites / read-later: `Ctrl+F` stars the current item from the list or while reading, `F3` opens the cross-source favorites list (newest first, Enter fetches latest comments from the original source); a full snapshot is stored in `favorites.json` so items stay readable after they leave the feeds, favorited entries are exempt from the read-state cap, and `feedscope favorites [export [file.md]]` lists / exports them as Markdown
|
|
16
|
+
- Source-tag column for mixed lists: the RSS / Telegram "All" aggregate streams label each row's origin (feed / channel name) in a dedicated left-aligned column instead of prefixing it into the right-aligned meta column, which made column edges ragged; the favorites list shows the item's source the same way (a favorited aggregate item shows its feed/channel name)
|
|
17
|
+
- Offline list cache: each feed is cached to its own JSON file (200 items per feed, 60 most recent feeds kept); startup and refresh paint the cache first (instant restarts), and a failed refresh keeps the old list on screen instead of clearing it — RSS / Telegram detail bodies travel with the cached list and read offline
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- The unread dot was invisible on the selected row: the highlight background and the dot both use the editor blue (blue-on-blue). On a highlighted row the dot now follows the selection text color; in the sidebar-focused state (highlight suppressed, no blue band) it keeps the editor blue
|
|
22
|
+
|
|
8
23
|
## [0.1.0] - 2026-11-07
|
|
9
24
|
|
|
10
25
|
Initial release.
|
|
@@ -22,4 +37,5 @@ Initial release.
|
|
|
22
37
|
- Editor-inspired VS Code Dark+ theme; every keybinding is a non-printing key (works while an IME is active); top tab bar and sidebar are clickable
|
|
23
38
|
- Layout-change self-check: 0 items parsed → immediate "site may have changed" error; `feedscope probe` inspects all sources with raw samples saved to disk; `feedscope doctor` environment self-check; `feedscope rss` CLI for subscription management
|
|
24
39
|
|
|
40
|
+
[0.2.0]: https://github.com/howardli/FeedScope/releases/tag/v0.2.0
|
|
25
41
|
[0.1.0]: https://github.com/howardli/FeedScope/releases/tag/v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: feedscope
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: FeedScope (观流): a terminal feed reader — Zhihu / X / Reddit / HN / V2EX / RSS / Telegram, all in one terminal
|
|
5
5
|
Project-URL: Homepage, https://github.com/howardli/FeedScope
|
|
6
6
|
Project-URL: Repository, https://github.com/howardli/FeedScope
|
|
@@ -51,6 +51,8 @@ Zhihu Hot → X timeline → post detail (auto translation, quoted post) → Red
|
|
|
51
51
|
- **Multi-source aggregation**: Zhihu Hot / Recommend / Following, X For You / Following, Reddit Home / Popular / custom subreddits, HN Front / New / Ask / Show, V2EX Hot / Latest, Telegram channel subscriptions — one UI, `Tab` to switch
|
|
52
52
|
- **RSS subscriptions**: any RSS / Atom feed; the "All" stream merges by publish time (cross-feed dedup, a dead feed never drags others down); read feed-provided full text in-terminal; conditional requests keep refreshes polite to sources
|
|
53
53
|
- **Full in-terminal reading**: feed → question → answers → comments, loaded per layer on demand
|
|
54
|
+
- **Read state & offline**: the row-leading `●` marks an item unread and clears when you open it (`Ctrl+R` toggles back) — refreshes and restarts never touch it; the "N new" count lives in the status line; lists are cached locally, so restarts paint instantly and offline keeps them readable (RSS / Telegram details read offline too)
|
|
55
|
+
- **Favorites / read-later**: `Ctrl+F` stars the current item (from the list or while reading), `F3` opens the cross-source list; a full snapshot is stored — items stay readable after they scroll off hot lists or get deleted, and `feedscope favorites export` takes them with you (no lock-in)
|
|
54
56
|
- **HN discussion tree**: top-level comments as floors, reply trees one `Enter` deeper; deeply nested threads are flattened to two levels with `@parent` prefixes preserving who-replies-to-whom
|
|
55
57
|
- **Auto Grok translation for foreign posts on X**; media markers `[图片]` / `[视频]` are clickable links to the original image / video
|
|
56
58
|
- **Login loop**: in-app Chrome QR / password login when a source needs it; `Ctrl+U` to switch accounts
|
|
@@ -139,6 +141,9 @@ feedscope
|
|
|
139
141
|
| `Enter` | Open detail; inside a detail, open the discussion layer of the floor you are reading; return to the list from the sidebar / source bar |
|
|
140
142
|
| `Ctrl+N` | Load more (list / floors / comments, each layer independently) |
|
|
141
143
|
| `Ctrl+O` | Open current item in browser |
|
|
144
|
+
| `Ctrl+R` | Toggle read / unread on the current item (mark it unread to revisit) |
|
|
145
|
+
| `Ctrl+F` | Favorite / unfavorite the current item (works while reading too) |
|
|
146
|
+
| `F3` | Favorites list: cross-source, newest first; Enter opens detail, Ctrl+F unfavorites |
|
|
142
147
|
| `F5` | Refresh |
|
|
143
148
|
| `Ctrl+U` | Account: login / logout |
|
|
144
149
|
| `Ctrl+L` | UI language: pick and save; restart `feedscope` to apply |
|
|
@@ -148,6 +153,22 @@ feedscope
|
|
|
148
153
|
|
|
149
154
|
All keys are non-printing — they work while a Chinese IME is active; typing printable characters by mistake shows a hint. The top tab bar and the left sidebar are clickable and drive the same code path as the keyboard. Arrow keys follow CLI conventions: `↑`/`↓` move the selection within the current list; `↑` on the first row of any region climbs to the source bar (the top region sits above everything), and `↓` from the source bar descends to the sidebar (inverse of the sidebar's first-feed `↑`); other region moves are horizontal — in the source bar `←`/`→` switches site in place; in the sidebar `↑`/`↓` switches feed in place, `→` returns to the list and `←` goes to the source bar; in the list `←` enters the sidebar (staying on the current feed) and `→` hits the right edge; `Enter` / `Esc` return to the list at any time. Dialogs (source manager / subscriptions / input / confirm / account) support the same arrow-key focus movement: `↓` past the bottom of the list enters the button row, `↑` returns to the list, `←`/`→` move between buttons, and `Enter` presses the focused button.
|
|
150
155
|
|
|
156
|
+
### Read state & offline
|
|
157
|
+
|
|
158
|
+
The dot column on each row tracks reading state: `●` unread (editor blue), blank = read. Unread is persistent — refreshes, feed switches and restarts never touch it; the dot only clears when you open the item (`Enter` into detail, `Ctrl+O` to the browser, or `←`/`→` article-stepping inside a detail). `Ctrl+R` toggles the current item — mark it unread to revisit. How many items arrived with this refresh shows as a count in the status line ("N items · M new"), not as row-level marks.
|
|
159
|
+
|
|
160
|
+
Favorites (`Ctrl+F`) store a **full snapshot at the moment of starring** (title/link/meta, and the body for RSS / Telegram) — items stay readable after they scroll off hot lists or get deleted. The `F3` list reads snapshots with zero requests (works offline); Enter fetches the latest comments from the original source. Favorited entries are exempt from the read-state cap; `feedscope favorites` lists them in the terminal and `feedscope favorites export [file.md]` exports Markdown to take them anywhere.
|
|
161
|
+
|
|
162
|
+
Both live in plain local files (delete to reset; no credentials involved):
|
|
163
|
+
|
|
164
|
+
| File | Contents |
|
|
165
|
+
| --- | --- |
|
|
166
|
+
| `state.json` (platform state dir, `~/.local/state/feedscope/` on Linux) | read/seen/favorite item ids with timestamps (capped at 10k entries, oldest evicted first; favorites exempt) |
|
|
167
|
+
| `favorites.json` (same state dir) | full snapshots of favorited items (user-curated data, never pruned) |
|
|
168
|
+
| `feeds/` (platform cache dir, `~/.cache/feedscope/feeds/` on Linux) | one JSON list cache per feed (200 items per feed, 60 most recent feeds kept) |
|
|
169
|
+
|
|
170
|
+
Startup and refresh paint the cache before fetching: restarts show the list instantly; offline or a broken site keeps the old content readable, and RSS / Telegram detail bodies travel with the cached list. `feedscope doctor` reports the state-file location.
|
|
171
|
+
|
|
151
172
|
### UI Language
|
|
152
173
|
|
|
153
174
|
The first launch asks for the language (pre-selected from your system locale; `Enter` to confirm, `Esc` keeps the default). Change it anytime with `Ctrl+L`; the choice is saved to the config and **takes effect when you restart `feedscope`** (counts and unit words are baked into fetched data in the process language, so a fixed-per-process language is the most robust model). **English is the default when no language is configured** — 中文用户在首启回车即选简体中文,或 `Ctrl+L` 随时切换。Subcommands (`doctor` / `probe` / `rss` / `login`) follow the same language as the TUI:
|
|
@@ -186,6 +207,7 @@ feedscope doctor # environment self-check (Chrome / profile / config)
|
|
|
186
207
|
feedscope rss add <url> [--key k --label name] # add: feed URL / site homepage (auto-discovered) / OPML path
|
|
187
208
|
feedscope rss list | remove <key> # list / remove subscriptions
|
|
188
209
|
feedscope rss import <x.opml> | export [x.opml] # bulk import (idempotent merge) / export
|
|
210
|
+
feedscope favorites [export [x.md]] # list favorites / export as Markdown (no lock-in)
|
|
189
211
|
```
|
|
190
212
|
|
|
191
213
|
### Source & Feed Config
|
|
@@ -276,7 +298,7 @@ No login; data comes from each channel's public web preview (`t.me/s/<channel>`,
|
|
|
276
298
|
|
|
277
299
|
- Pure subscription source shaped exactly like RSS: `F2` → Telegram row → `→` opens the manager; type a channel name or link (`durov`, `@durov`, `t.me/durov`, even a post link); label defaults to the channel's own name
|
|
278
300
|
- The "All" stream merges every channel by publish time (newest first), one dead channel only loses its own items; each channel is also its own feed (individually hideable)
|
|
279
|
-
- Meta column "N views" is the channel-post heat radar; the "All" stream
|
|
301
|
+
- Meta column "N views" is the channel-post heat radar; the "All" stream labels each row's origin in a dedicated source-tag column (same as the favorites list); inline HTML becomes markdown, images/videos become `[图片]` / `[视频]` markers (videos have no direct link — the marker points to the post page)
|
|
280
302
|
- Details read list-carried content (zero requests); `Ctrl+N` in a single-channel feed pages back through history via the `?before=` cursor; no comment layer (channel discussion groups aren't in the preview)
|
|
281
303
|
|
|
282
304
|
## Community
|
|
@@ -20,6 +20,8 @@ Zhihu Hot → X timeline → post detail (auto translation, quoted post) → Red
|
|
|
20
20
|
- **Multi-source aggregation**: Zhihu Hot / Recommend / Following, X For You / Following, Reddit Home / Popular / custom subreddits, HN Front / New / Ask / Show, V2EX Hot / Latest, Telegram channel subscriptions — one UI, `Tab` to switch
|
|
21
21
|
- **RSS subscriptions**: any RSS / Atom feed; the "All" stream merges by publish time (cross-feed dedup, a dead feed never drags others down); read feed-provided full text in-terminal; conditional requests keep refreshes polite to sources
|
|
22
22
|
- **Full in-terminal reading**: feed → question → answers → comments, loaded per layer on demand
|
|
23
|
+
- **Read state & offline**: the row-leading `●` marks an item unread and clears when you open it (`Ctrl+R` toggles back) — refreshes and restarts never touch it; the "N new" count lives in the status line; lists are cached locally, so restarts paint instantly and offline keeps them readable (RSS / Telegram details read offline too)
|
|
24
|
+
- **Favorites / read-later**: `Ctrl+F` stars the current item (from the list or while reading), `F3` opens the cross-source list; a full snapshot is stored — items stay readable after they scroll off hot lists or get deleted, and `feedscope favorites export` takes them with you (no lock-in)
|
|
23
25
|
- **HN discussion tree**: top-level comments as floors, reply trees one `Enter` deeper; deeply nested threads are flattened to two levels with `@parent` prefixes preserving who-replies-to-whom
|
|
24
26
|
- **Auto Grok translation for foreign posts on X**; media markers `[图片]` / `[视频]` are clickable links to the original image / video
|
|
25
27
|
- **Login loop**: in-app Chrome QR / password login when a source needs it; `Ctrl+U` to switch accounts
|
|
@@ -108,6 +110,9 @@ feedscope
|
|
|
108
110
|
| `Enter` | Open detail; inside a detail, open the discussion layer of the floor you are reading; return to the list from the sidebar / source bar |
|
|
109
111
|
| `Ctrl+N` | Load more (list / floors / comments, each layer independently) |
|
|
110
112
|
| `Ctrl+O` | Open current item in browser |
|
|
113
|
+
| `Ctrl+R` | Toggle read / unread on the current item (mark it unread to revisit) |
|
|
114
|
+
| `Ctrl+F` | Favorite / unfavorite the current item (works while reading too) |
|
|
115
|
+
| `F3` | Favorites list: cross-source, newest first; Enter opens detail, Ctrl+F unfavorites |
|
|
111
116
|
| `F5` | Refresh |
|
|
112
117
|
| `Ctrl+U` | Account: login / logout |
|
|
113
118
|
| `Ctrl+L` | UI language: pick and save; restart `feedscope` to apply |
|
|
@@ -117,6 +122,22 @@ feedscope
|
|
|
117
122
|
|
|
118
123
|
All keys are non-printing — they work while a Chinese IME is active; typing printable characters by mistake shows a hint. The top tab bar and the left sidebar are clickable and drive the same code path as the keyboard. Arrow keys follow CLI conventions: `↑`/`↓` move the selection within the current list; `↑` on the first row of any region climbs to the source bar (the top region sits above everything), and `↓` from the source bar descends to the sidebar (inverse of the sidebar's first-feed `↑`); other region moves are horizontal — in the source bar `←`/`→` switches site in place; in the sidebar `↑`/`↓` switches feed in place, `→` returns to the list and `←` goes to the source bar; in the list `←` enters the sidebar (staying on the current feed) and `→` hits the right edge; `Enter` / `Esc` return to the list at any time. Dialogs (source manager / subscriptions / input / confirm / account) support the same arrow-key focus movement: `↓` past the bottom of the list enters the button row, `↑` returns to the list, `←`/`→` move between buttons, and `Enter` presses the focused button.
|
|
119
124
|
|
|
125
|
+
### Read state & offline
|
|
126
|
+
|
|
127
|
+
The dot column on each row tracks reading state: `●` unread (editor blue), blank = read. Unread is persistent — refreshes, feed switches and restarts never touch it; the dot only clears when you open the item (`Enter` into detail, `Ctrl+O` to the browser, or `←`/`→` article-stepping inside a detail). `Ctrl+R` toggles the current item — mark it unread to revisit. How many items arrived with this refresh shows as a count in the status line ("N items · M new"), not as row-level marks.
|
|
128
|
+
|
|
129
|
+
Favorites (`Ctrl+F`) store a **full snapshot at the moment of starring** (title/link/meta, and the body for RSS / Telegram) — items stay readable after they scroll off hot lists or get deleted. The `F3` list reads snapshots with zero requests (works offline); Enter fetches the latest comments from the original source. Favorited entries are exempt from the read-state cap; `feedscope favorites` lists them in the terminal and `feedscope favorites export [file.md]` exports Markdown to take them anywhere.
|
|
130
|
+
|
|
131
|
+
Both live in plain local files (delete to reset; no credentials involved):
|
|
132
|
+
|
|
133
|
+
| File | Contents |
|
|
134
|
+
| --- | --- |
|
|
135
|
+
| `state.json` (platform state dir, `~/.local/state/feedscope/` on Linux) | read/seen/favorite item ids with timestamps (capped at 10k entries, oldest evicted first; favorites exempt) |
|
|
136
|
+
| `favorites.json` (same state dir) | full snapshots of favorited items (user-curated data, never pruned) |
|
|
137
|
+
| `feeds/` (platform cache dir, `~/.cache/feedscope/feeds/` on Linux) | one JSON list cache per feed (200 items per feed, 60 most recent feeds kept) |
|
|
138
|
+
|
|
139
|
+
Startup and refresh paint the cache before fetching: restarts show the list instantly; offline or a broken site keeps the old content readable, and RSS / Telegram detail bodies travel with the cached list. `feedscope doctor` reports the state-file location.
|
|
140
|
+
|
|
120
141
|
### UI Language
|
|
121
142
|
|
|
122
143
|
The first launch asks for the language (pre-selected from your system locale; `Enter` to confirm, `Esc` keeps the default). Change it anytime with `Ctrl+L`; the choice is saved to the config and **takes effect when you restart `feedscope`** (counts and unit words are baked into fetched data in the process language, so a fixed-per-process language is the most robust model). **English is the default when no language is configured** — 中文用户在首启回车即选简体中文,或 `Ctrl+L` 随时切换。Subcommands (`doctor` / `probe` / `rss` / `login`) follow the same language as the TUI:
|
|
@@ -155,6 +176,7 @@ feedscope doctor # environment self-check (Chrome / profile / config)
|
|
|
155
176
|
feedscope rss add <url> [--key k --label name] # add: feed URL / site homepage (auto-discovered) / OPML path
|
|
156
177
|
feedscope rss list | remove <key> # list / remove subscriptions
|
|
157
178
|
feedscope rss import <x.opml> | export [x.opml] # bulk import (idempotent merge) / export
|
|
179
|
+
feedscope favorites [export [x.md]] # list favorites / export as Markdown (no lock-in)
|
|
158
180
|
```
|
|
159
181
|
|
|
160
182
|
### Source & Feed Config
|
|
@@ -245,7 +267,7 @@ No login; data comes from each channel's public web preview (`t.me/s/<channel>`,
|
|
|
245
267
|
|
|
246
268
|
- Pure subscription source shaped exactly like RSS: `F2` → Telegram row → `→` opens the manager; type a channel name or link (`durov`, `@durov`, `t.me/durov`, even a post link); label defaults to the channel's own name
|
|
247
269
|
- The "All" stream merges every channel by publish time (newest first), one dead channel only loses its own items; each channel is also its own feed (individually hideable)
|
|
248
|
-
- Meta column "N views" is the channel-post heat radar; the "All" stream
|
|
270
|
+
- Meta column "N views" is the channel-post heat radar; the "All" stream labels each row's origin in a dedicated source-tag column (same as the favorites list); inline HTML becomes markdown, images/videos become `[图片]` / `[视频]` markers (videos have no direct link — the marker points to the post page)
|
|
249
271
|
- Details read list-carried content (zero requests); `Ctrl+N` in a single-channel feed pages back through history via the `?before=` cursor; no comment layer (channel discussion groups aren't in the preview)
|
|
250
272
|
|
|
251
273
|
## Community
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
- **多源聚合**:知乎热榜 / 推荐 / 关注,X 推荐 / 正在关注,Reddit 首页 / 热门 / 自选 subreddit,HN 首页 / 最新 / Ask / Show,V2EX 热门 / 最新,Telegram 频道订阅,一个界面切换
|
|
21
21
|
- **RSS 订阅**:任意 RSS / Atom 源,「全部」流按时间合并(跨订阅去重、单源失败不拖垮),终端内读 feed 自带全文,条件请求刷新对源友好
|
|
22
22
|
- **终端内完整阅读**:信息流 → 问题 → 回答 → 评论,逐层按需加载
|
|
23
|
+
- **已读与未读**:行首 `●` 标未读,打开即灭(`Ctrl+R` 可改回),刷新/重启都不动它;「N 新」计数在顶部状态行;列表缓存本地,重启秒开、断网可翻(RSS / Telegram 详情离线可读)
|
|
24
|
+
- **收藏/稍后读**:`Ctrl+F` 收藏(列表或阅读中都行),`F3` 看跨源收藏列表;存全量快照——条目从热榜流走、回答被删之后仍可读,`feedscope favorites export` 随时带走防锁定
|
|
23
25
|
- **HN 讨论树**:顶层评论当楼层、回复树再深一层(`Enter`),深层嵌套拍平两层、`@父作者` 标出回复指向
|
|
24
26
|
- **X 外文推自动附 Grok 译文**,媒体记号 `[图片]` / `[视频]` 可点击直达原图 / 视频直链
|
|
25
27
|
- **登录闭环**:未登录自动弹 Chrome 扫码 / 输密码,`Ctrl+U` 一键登出换账号
|
|
@@ -108,6 +110,9 @@ feedscope
|
|
|
108
110
|
| `Enter` | 进详情;详情屏内进「正在阅读」的那个楼层(回答 / 评论)的讨论层;边栏 / 源栏聚焦时回列表 |
|
|
109
111
|
| `Ctrl+N` | 加载更多(列表 / 楼层 / 评论,逐层各自生效) |
|
|
110
112
|
| `Ctrl+O` | 浏览器打开当前条目 |
|
|
113
|
+
| `Ctrl+R` | 当前条目已读 / 未读切换(想重看的标回未读) |
|
|
114
|
+
| `Ctrl+F` | 收藏 / 取消收藏当前条目(阅读中也生效) |
|
|
115
|
+
| `F3` | 收藏列表:跨源按收藏时间倒序,Enter 进详情,Ctrl+F 取消 |
|
|
111
116
|
| `F5` | 刷新 |
|
|
112
117
|
| `Ctrl+U` | 账号:登录 / 登出 |
|
|
113
118
|
| `Ctrl+L` | 界面语言:选择后保存,重启 `feedscope` 生效 |
|
|
@@ -117,6 +122,22 @@ feedscope
|
|
|
117
122
|
|
|
118
123
|
键位全部为非打印键,输入法中文状态照常可用;误把中文打进来会弹提示。顶部 tab 条与左侧边栏可点击,与键盘走同一条路径。方向键按 CLI 惯例处理:`↑`/`↓` 在当前列表里移动选中;任何区域的首行再 `↑` 都上探源栏(顶部区域在所有内容之上),源栏 `↓` 下探边栏(与边栏首流 `↑` 互逆);其余区域移动走横向——源栏里 `←`/`→` 就地切站,边栏里 `↑`/`↓` 就地切流、`→` 回列表、`←` 去源栏,列表里 `←` 进边栏(停当前流)、`→` 抵右缘;`Enter` / `Esc` 随时回列表。弹层(源管理 / 订阅管理 / 输入 / 确认 / 账号)里方向键同样移动焦点:列表到底再 `↓` 进按钮行、`↑` 返回列表、`←`/`→` 在按钮间平移、`Enter` 按下聚焦的按钮。
|
|
119
124
|
|
|
125
|
+
### 已读与离线
|
|
126
|
+
|
|
127
|
+
行首圆点标记阅读状态:`●` 未读(编辑器蓝)、空白 = 已读。未读是持久状态——刷新、切流、重启都不会动它,圆点只随「打开」消失(`Enter` 进详情、`Ctrl+O` 开浏览器、详情屏 `←`/`→` 换篇都算);`Ctrl+R` 手动切换当前条目,想重看的标回未读。本次刷新首见几条会计数在顶部状态行(「N 条 · M 新」),不做行级标记。
|
|
128
|
+
|
|
129
|
+
收藏(`Ctrl+F`)存的是**收藏那一刻的全量快照**(标题/链接/元信息,RSS / Telegram 连正文一起)——条目从热榜流走、回答被删之后收藏仍可读;`F3` 列表读快照零请求、离线可用,Enter 才按原源在线抓最新评论。收藏条目豁免已读状态的淘汰;`feedscope favorites` 终端看列表、`feedscope favorites export [x.md]` 导出 Markdown 随时带走。
|
|
130
|
+
|
|
131
|
+
状态与缓存都是本机纯文本文件(删掉即重置,不含任何凭据):
|
|
132
|
+
|
|
133
|
+
| 文件 | 内容 |
|
|
134
|
+
| --- | --- |
|
|
135
|
+
| `state.json`(状态目录,Linux 为 `~/.local/state/feedscope/`) | 已读/已见/收藏条目 id 与时间戳(上限一万条按最旧淘汰;收藏条目豁免) |
|
|
136
|
+
| `favorites.json`(同状态目录) | 收藏条目的全量快照(用户数据,永不淘汰) |
|
|
137
|
+
| `feeds/`(缓存目录,Linux 为 `~/.cache/feedscope/feeds/`) | 每流一个 JSON 的列表缓存(每流 200 条、留最近 60 流) |
|
|
138
|
+
|
|
139
|
+
启动或刷新先画缓存再抓新数据:重启秒开列表;断网或站点改版时旧内容保留可翻,RSS / Telegram 的详情正文随列表缓存离线可读。`feedscope doctor` 会列出状态文件位置。
|
|
140
|
+
|
|
120
141
|
### 界面语言
|
|
121
142
|
|
|
122
143
|
首启先弹语言选择(预选跟随系统语言,`Enter` 确认、`Esc` 沿用默认),之后 `Ctrl+L` 随时修改;修改保存到配置后**重启 `feedscope` 生效**(数字/单位等文案在抓取时就随语言烙进数据,语言随进程固定是最稳的模型)。**未配置语言时默认英文**——中文用户首启直接回车选简体中文,或 `Ctrl+L` 随时切换。子命令(`doctor` / `probe` / `rss` / `login`)的输出与 TUI 同语言:
|
|
@@ -155,6 +176,7 @@ feedscope doctor # 环境自检(Chrome / profile / 配置)
|
|
|
155
176
|
feedscope rss add <url> [--key k --label 名] # 加订阅:feed 直链 / 站点首页(自动发现)/ OPML 路径
|
|
156
177
|
feedscope rss list | remove <key> # 查看 / 删除订阅
|
|
157
178
|
feedscope rss import <x.opml> | export [x.opml] # OPML 批量迁入(幂等合并)/ 导出
|
|
179
|
+
feedscope favorites [export [x.md]] # 收藏列表 / 导出 Markdown(防锁定)
|
|
158
180
|
```
|
|
159
181
|
|
|
160
182
|
### 源与信息流配置
|
|
@@ -245,7 +267,7 @@ hide = ["all"] # 可隐藏「全部」聚合流,或填订阅 key 隐藏
|
|
|
245
267
|
|
|
246
268
|
- 与 RSS 同形的纯订阅源:`F2` → Telegram 行按 `→` 进订阅管理,输入频道名或链接(`durov`、`@durov`、`t.me/durov`、帖子直链都认),显示名缺省拉频道名
|
|
247
269
|
- 「全部」流 = 全部频道按发布时间倒序合并,单频道失败只损失自己的条目;每频道也各自成流(可 `hide` 单藏)
|
|
248
|
-
- 元信息列「N
|
|
270
|
+
- 元信息列「N 观看」是频道帖的热度雷达;「全部」流里每行带源名列标频道名(与收藏列表同款,出处成列不挤元信息);正文行内 HTML 转 markdown、图片/视频换 `[图片]` / `[视频]` 记号(视频无直链,记号指向帖子页)
|
|
249
271
|
- 详情读列表自带正文(零请求);单频道流 `Ctrl+N` 走 `?before=` 游标往回翻历史帖;无评论层(频道讨论组在预览页拿不到)
|
|
250
272
|
|
|
251
273
|
## 社区
|
|
@@ -13,6 +13,9 @@ where the sensitive bits live, so you can audit that claim:
|
|
|
13
13
|
| --- | --- | --- |
|
|
14
14
|
| User config | `sources.toml` in your platform config dir (`~/.config/feedscope/` on Linux, `~/Library/Application Support/feedscope/` on macOS) | No — language, enabled sources, subscriptions only |
|
|
15
15
|
| Browser profile | `chromium-profile/` in your platform cache dir (`~/.cache/feedscope/` on Linux, `~/Library/Caches/feedscope/` on macOS) | **Yes** — Chrome profile with your Zhihu / X / Reddit session cookies |
|
|
16
|
+
| Read state | `state.json` in your platform state dir (`~/.local/state/feedscope/` on Linux) | No — item ids and timestamps only |
|
|
17
|
+
| Favorites | `favorites.json` next to `state.json` | No — snapshots (title/link/body) of public items you starred |
|
|
18
|
+
| List cache | `feeds/` next to the browser profile in your platform cache dir | No — titles, links and bodies of the public feeds you subscribe to |
|
|
16
19
|
|
|
17
20
|
Guarantees:
|
|
18
21
|
|
|
@@ -93,10 +93,20 @@ def main() -> None:
|
|
|
93
93
|
from feedscope.core.registry import install_sources, source_classes
|
|
94
94
|
from feedscope.ui import app as app_mod
|
|
95
95
|
|
|
96
|
-
# 隔离本机用户配置(与 screenshot.main 同一套路:避开首启引导 / 隐藏流漂移 / 真 Chrome
|
|
96
|
+
# 隔离本机用户配置(与 screenshot.main 同一套路:避开首启引导 / 隐藏流漂移 / 真 Chrome 预热)。
|
|
97
|
+
# 状态/缓存同样钉住并先清:GIF 想要「首跑全 ● → Enter 逐条清点」的叙事,
|
|
98
|
+
# 上一次运行的残留已读会让首帧就带空点
|
|
97
99
|
cfg = DOCS.parent / ".screenshot-sources.toml"
|
|
98
100
|
cfg.write_text('enabled = ["zhihu", "x", "reddit", "hn", "v2ex"]\n', encoding="utf-8")
|
|
101
|
+
state = DOCS.parent / ".screenshot-state.json"
|
|
102
|
+
state.unlink(missing_ok=True)
|
|
103
|
+
cache = DOCS.parent / ".screenshot-feeds"
|
|
104
|
+
if cache.exists():
|
|
105
|
+
for p in cache.iterdir():
|
|
106
|
+
p.unlink()
|
|
99
107
|
os.environ["FEEDSCOPE_CONFIG"] = str(cfg)
|
|
108
|
+
os.environ["FEEDSCOPE_STATE"] = str(state)
|
|
109
|
+
os.environ["FEEDSCOPE_FEED_CACHE"] = str(cache)
|
|
100
110
|
os.environ["FEEDSCOPE_NO_PREWARM"] = "1"
|
|
101
111
|
|
|
102
112
|
frames: list[Path] = []
|
|
@@ -746,6 +746,7 @@ async def _shots() -> None:
|
|
|
746
746
|
for _ in range(6):
|
|
747
747
|
await pilot.pause()
|
|
748
748
|
DOCS.mkdir(exist_ok=True)
|
|
749
|
+
await _demo_read_mix(app, pilot)
|
|
749
750
|
_emit(app, "screenshot-zhihu")
|
|
750
751
|
await pilot.press("tab") # Tab 切到 X
|
|
751
752
|
for _ in range(6):
|
|
@@ -753,6 +754,7 @@ async def _shots() -> None:
|
|
|
753
754
|
await pilot.press("enter") # 切站落边栏:Enter 回列表
|
|
754
755
|
for _ in range(2):
|
|
755
756
|
await pilot.pause()
|
|
757
|
+
await _demo_read_mix(app, pilot)
|
|
756
758
|
_emit(app, "screenshot-x")
|
|
757
759
|
await pilot.press("enter") # 进首条推文详情(外文 + 译文 + 引用块)
|
|
758
760
|
for _ in range(8):
|
|
@@ -765,6 +767,7 @@ async def _shots() -> None:
|
|
|
765
767
|
await pilot.press("enter") # 切站落边栏:Enter 回列表
|
|
766
768
|
for _ in range(2):
|
|
767
769
|
await pilot.pause()
|
|
770
|
+
await _demo_read_mix(app, pilot)
|
|
768
771
|
_emit(app, "screenshot-reddit")
|
|
769
772
|
await pilot.press("enter") # 进帖子详情(markdown 自述 + 置顶/赞数评论楼层)
|
|
770
773
|
for _ in range(8):
|
|
@@ -777,6 +780,7 @@ async def _shots() -> None:
|
|
|
777
780
|
await pilot.press("enter") # 切站落边栏:Enter 回列表
|
|
778
781
|
for _ in range(2):
|
|
779
782
|
await pilot.pause()
|
|
783
|
+
await _demo_read_mix(app, pilot)
|
|
780
784
|
_emit(app, "screenshot-hn")
|
|
781
785
|
await pilot.press("enter") # 进 story 详情(原文一行 + 评论楼层)
|
|
782
786
|
for _ in range(8):
|
|
@@ -794,6 +798,7 @@ async def _shots() -> None:
|
|
|
794
798
|
await pilot.press("enter") # 切站落边栏:Enter 回列表
|
|
795
799
|
for _ in range(2):
|
|
796
800
|
await pilot.pause()
|
|
801
|
+
await _demo_read_mix(app, pilot)
|
|
797
802
|
_emit(app, "screenshot-v2ex")
|
|
798
803
|
await pilot.press("enter") # 进主题详情(markdown 正文 + 回复楼层)
|
|
799
804
|
for _ in range(8):
|
|
@@ -801,6 +806,22 @@ async def _shots() -> None:
|
|
|
801
806
|
_emit(app, "screenshot-v2ex-detail")
|
|
802
807
|
|
|
803
808
|
|
|
809
|
+
async def _demo_read_mix(app: app_mod.FeedScopeApp, pilot) -> None:
|
|
810
|
+
"""列表截图的已读混合态:每隔三条标一条已读再重排。
|
|
811
|
+
|
|
812
|
+
截图要同时展示圆点三态(● 新 / ○ 未读 / 空 已读)——全新状态全屏 ●
|
|
813
|
+
看不出「已读会变空」,全已读又看不出特性;隔条混合最像真实使用痕迹。
|
|
814
|
+
"""
|
|
815
|
+
main = app.main
|
|
816
|
+
if main is None or not main.items:
|
|
817
|
+
return
|
|
818
|
+
for it in main.items[::3]:
|
|
819
|
+
app.state.mark_read(it.source, it.id)
|
|
820
|
+
await main.rebuild_items()
|
|
821
|
+
for _ in range(2):
|
|
822
|
+
await pilot.pause()
|
|
823
|
+
|
|
824
|
+
|
|
804
825
|
def _emit(app: app_mod.FeedScopeApp, name: str) -> None:
|
|
805
826
|
"""导出一张:SVG -> 修 CJK 格宽 -> 栅格化 PNG。"""
|
|
806
827
|
svg, fixed = _fix_cjk_metrics(app.export_screenshot())
|
|
@@ -811,10 +832,20 @@ def _emit(app: app_mod.FeedScopeApp, name: str) -> None:
|
|
|
811
832
|
|
|
812
833
|
def main() -> None:
|
|
813
834
|
# 隔离本机用户配置(隐藏流会让侧栏条目数漂移);预写 enabled 跳过首启引导
|
|
814
|
-
# (引导是交互弹层,自动化流程过不去);FEEDSCOPE_NO_PREWARM 关掉不拉真 Chrome
|
|
835
|
+
# (引导是交互弹层,自动化流程过不去);FEEDSCOPE_NO_PREWARM 关掉不拉真 Chrome。
|
|
836
|
+
# 已读状态/列表缓存同样钉在仓库根(gitignore),先清再跑:读/缓存都会让截图
|
|
837
|
+
# 漂移(混合已读态由 _demo_read_mix 脚本化生成,不靠上一次运行的残留)
|
|
815
838
|
cfg = DOCS.parent / ".screenshot-sources.toml"
|
|
816
839
|
cfg.write_text('enabled = ["zhihu", "x", "reddit", "hn", "v2ex"]\n', encoding="utf-8")
|
|
840
|
+
state = DOCS.parent / ".screenshot-state.json"
|
|
841
|
+
state.unlink(missing_ok=True)
|
|
842
|
+
cache = DOCS.parent / ".screenshot-feeds"
|
|
843
|
+
if cache.exists():
|
|
844
|
+
for p in cache.iterdir():
|
|
845
|
+
p.unlink()
|
|
817
846
|
os.environ["FEEDSCOPE_CONFIG"] = str(cfg)
|
|
847
|
+
os.environ["FEEDSCOPE_STATE"] = str(state)
|
|
848
|
+
os.environ["FEEDSCOPE_FEED_CACHE"] = str(cache)
|
|
818
849
|
os.environ["FEEDSCOPE_NO_PREWARM"] = "1"
|
|
819
850
|
saved = source_classes()
|
|
820
851
|
install_sources([DemoZhihu, DemoX, DemoReddit, DemoHn, DemoV2ex])
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""命令行入口:`feedscope` 启动 TUI;子命令 login / probe / doctor / rss。"""
|
|
1
|
+
"""命令行入口:`feedscope` 启动 TUI;子命令 login / probe / doctor / rss / favorites。"""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -38,6 +38,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
38
38
|
export_p = rss_sub.add_parser("export", help=t("cli.help_rss_export"))
|
|
39
39
|
export_p.add_argument("path", nargs="?", help=t("cli.help_rss_export_path"))
|
|
40
40
|
|
|
41
|
+
fav = sub.add_parser("favorites", help=t("cli.help_favorites"))
|
|
42
|
+
fav_sub = fav.add_subparsers(dest="fav_command")
|
|
43
|
+
fav_export = fav_sub.add_parser("export", help=t("cli.help_favorites_export"))
|
|
44
|
+
fav_export.add_argument("path", nargs="?", help=t("cli.help_favorites_export_path"))
|
|
45
|
+
|
|
41
46
|
return parser
|
|
42
47
|
|
|
43
48
|
|
|
@@ -116,6 +121,39 @@ def _run_rss(args: argparse.Namespace) -> int:
|
|
|
116
121
|
return 0
|
|
117
122
|
|
|
118
123
|
|
|
124
|
+
def _run_favorites(args: argparse.Namespace) -> int:
|
|
125
|
+
"""favorites 子命令:无子命令 = 明文列表;export = Markdown(防锁定,同 rss export)。"""
|
|
126
|
+
import time
|
|
127
|
+
|
|
128
|
+
from feedscope.core.store import ReadState
|
|
129
|
+
|
|
130
|
+
pairs = ReadState().favorites()
|
|
131
|
+
if args.fav_command != "export":
|
|
132
|
+
if not pairs:
|
|
133
|
+
print(t("cli.favorites_empty"))
|
|
134
|
+
return 0
|
|
135
|
+
for ts, it in pairs:
|
|
136
|
+
date = time.strftime("%Y-%m-%d", time.localtime(ts))
|
|
137
|
+
print(f"{it.source:<10} {date} {it.title} <{it.url}>")
|
|
138
|
+
print(t("fav.status_count", n=len(pairs)))
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
# Markdown 链接的方括号/圆括号会破坏语法,标题与 URL 做最小转义
|
|
142
|
+
lines = [
|
|
143
|
+
f"- [{it.title.replace('[', '(').replace(']', ')')}]"
|
|
144
|
+
f"({it.url.replace('(', '%28').replace(')', '%29')}) — {it.source},"
|
|
145
|
+
f"{time.strftime('%Y-%m-%d', time.localtime(ts))}"
|
|
146
|
+
for ts, it in pairs
|
|
147
|
+
]
|
|
148
|
+
md = "\n".join(lines) + "\n" if lines else ""
|
|
149
|
+
if args.path:
|
|
150
|
+
Path(args.path).write_text(md, encoding="utf-8")
|
|
151
|
+
print(t("cli.favorites_exported", n=len(pairs), path=args.path))
|
|
152
|
+
else:
|
|
153
|
+
print(md, end="")
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
119
157
|
def main(argv: list[str] | None = None) -> int:
|
|
120
158
|
# 进程语言按配置设定(未设置 = 缺省):CLI 的 help/输出与 TUI 同语言;
|
|
121
159
|
# 必须在 build_parser 之前——argparse 的 help 在构造时就成型
|
|
@@ -151,4 +189,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
151
189
|
if args.command == "rss":
|
|
152
190
|
return _run_rss(args)
|
|
153
191
|
|
|
192
|
+
if args.command == "favorites":
|
|
193
|
+
return _run_favorites(args)
|
|
194
|
+
|
|
154
195
|
return 0
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""列表缓存:每流一个 JSON 文件,重启先画缓存秒开、抓取失败时兜底不清屏。
|
|
2
|
+
|
|
3
|
+
- 目录:FEEDSCOPE_FEED_CACHE 环境变量(测试隔离),缺省用户缓存目录下 feeds/
|
|
4
|
+
(platformdirs,与 chromium-profile / samples 同根)
|
|
5
|
+
- 每流一文件是硬约束不是风格:RSS 的 extra 带全文 content_html(单条 5-20KB),
|
|
6
|
+
聚合到单文件 JSON 后每次刷新任何一个流都要原子重写全部缓存(十几 MB 写
|
|
7
|
+
放大);拆文件后每次只重写本流自己的文件(几十 KB~几 MB)
|
|
8
|
+
- 缓存是派生数据:删除/损坏的最坏代价 = 下次刷新重抓,所以 LRU 淘汰(按
|
|
9
|
+
mtime 留最近 _MAX_FEEDS 个流)与坏文件容错都可以放心地激进
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from urllib.parse import quote
|
|
20
|
+
|
|
21
|
+
from platformdirs import user_cache_path
|
|
22
|
+
|
|
23
|
+
from feedscope.core.models import FeedItem
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
_MAX_FEEDS = 60 # LRU:超过即删最旧(切流频繁的缓存自然留大,冷流自然让位)
|
|
28
|
+
_MAX_ITEMS = 200 # 单流上限:取列表头部(各源列表都是新→旧序)
|
|
29
|
+
_NAME_CAP = 120 # 文件名上限(quote 后):防超长订阅 key 撞文件系统限制
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cache_dir() -> Path:
|
|
33
|
+
if p := os.environ.get("FEEDSCOPE_FEED_CACHE"):
|
|
34
|
+
return Path(p)
|
|
35
|
+
return user_cache_path("feedscope") / "feeds"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _file(source_id: str, feed: str) -> Path:
|
|
39
|
+
"""(source, feed) -> 缓存文件路径。
|
|
40
|
+
|
|
41
|
+
quote 编码非 URL 安全字符(中文订阅 key、路径分隔符都变成 %XX):
|
|
42
|
+
唯一、路径安全;只做正向映射不回解,寻址永远走 (source, feed) 入参。
|
|
43
|
+
超长名截断后补内容散列前缀,防不同长 key 截断后撞名。
|
|
44
|
+
"""
|
|
45
|
+
name = quote(f"{source_id}__{feed}", safe="")
|
|
46
|
+
if len(name) > _NAME_CAP:
|
|
47
|
+
digest = hashlib.sha1(name.encode()).hexdigest()[:8]
|
|
48
|
+
name = f"{name[:_NAME_CAP]}~{digest}"
|
|
49
|
+
return cache_dir() / f"{name}.json"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def save(source_id: str, feed: str, items: list[FeedItem]) -> None:
|
|
53
|
+
"""流刷新成功后整流覆写(头部 _MAX_ITEMS 条);空列表不落盘(无可兜底)。"""
|
|
54
|
+
if not items:
|
|
55
|
+
return
|
|
56
|
+
path = _file(source_id, feed)
|
|
57
|
+
try:
|
|
58
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
60
|
+
tmp.write_text(
|
|
61
|
+
json.dumps(
|
|
62
|
+
{"version": 1, "items": [it.to_dict() for it in items[:_MAX_ITEMS]]},
|
|
63
|
+
ensure_ascii=False,
|
|
64
|
+
separators=(",", ":"),
|
|
65
|
+
),
|
|
66
|
+
encoding="utf-8",
|
|
67
|
+
)
|
|
68
|
+
os.replace(tmp, path)
|
|
69
|
+
except OSError as exc:
|
|
70
|
+
logger.warning("列表缓存写入失败(%s/%s,忽略):%s", source_id, feed, exc)
|
|
71
|
+
_evict()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load(source_id: str, feed: str) -> list[FeedItem] | None:
|
|
75
|
+
"""读缓存;不存在/坏/形状不符 = None(调用方当作「无缓存」走正常空态)。"""
|
|
76
|
+
try:
|
|
77
|
+
raw = json.loads(_file(source_id, feed).read_text(encoding="utf-8"))
|
|
78
|
+
except FileNotFoundError:
|
|
79
|
+
return None
|
|
80
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
81
|
+
logger.warning("列表缓存读取失败(%s/%s,忽略):%s", source_id, feed, exc)
|
|
82
|
+
return None
|
|
83
|
+
rows = raw.get("items") if isinstance(raw, dict) else None
|
|
84
|
+
if not isinstance(rows, list):
|
|
85
|
+
return None
|
|
86
|
+
items = [FeedItem.from_dict(r) for r in rows if isinstance(r, dict)]
|
|
87
|
+
return items or None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _evict() -> None:
|
|
91
|
+
"""目录内 .json 超过 _MAX_FEEDS 个时删最旧(mtime = 最近一次 save)。"""
|
|
92
|
+
d = cache_dir()
|
|
93
|
+
try:
|
|
94
|
+
files = [p for p in d.iterdir() if p.suffix == ".json"]
|
|
95
|
+
except OSError:
|
|
96
|
+
return # 目录不存在(还没写过任何缓存)
|
|
97
|
+
if len(files) <= _MAX_FEEDS:
|
|
98
|
+
return
|
|
99
|
+
files.sort(key=lambda p: p.stat().st_mtime) # 调用点已兜 OSError,这里不再拦
|
|
100
|
+
for p in files[: len(files) - _MAX_FEEDS]:
|
|
101
|
+
try:
|
|
102
|
+
p.unlink()
|
|
103
|
+
except OSError:
|
|
104
|
+
pass # 并发/权限抖动:残留一个旧文件无害,下次淘汰再试
|