ForageFacebook 2.1.0__tar.gz → 2.3.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.
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/.github/workflows/ci.yml +4 -25
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/.gitignore +5 -0
- foragefacebook-2.3.0/.python-version +1 -0
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/AGENTS.md +12 -7
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/CHANGELOG.md +34 -1
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/CONTRIBUTING.md +16 -6
- foragefacebook-2.3.0/Makefile +25 -0
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/PKG-INFO +34 -8
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/README.md +33 -6
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/docs/capabilities.md +19 -8
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/pyproject.toml +1 -2
- foragefacebook-2.3.0/scripts/live-e2e.py +164 -0
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/src/forage/__init__.py +1 -1
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/src/forage/cli.py +30 -3
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/src/forage/parser.py +47 -26
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/src/forage/scraper.py +144 -189
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/uv.lock +2 -110
- foragefacebook-2.1.0/tests/__init__.py +0 -1
- foragefacebook-2.1.0/tests/conftest.py +0 -43
- foragefacebook-2.1.0/tests/fixtures/comment_simple.html +0 -7
- foragefacebook-2.1.0/tests/fixtures/comment_with_replies.html +0 -23
- foragefacebook-2.1.0/tests/fixtures/post_long_content.html +0 -13
- foragefacebook-2.1.0/tests/fixtures/post_realistic.html +0 -54
- foragefacebook-2.1.0/tests/fixtures/post_simple.html +0 -13
- foragefacebook-2.1.0/tests/fixtures/post_sponsored.html +0 -7
- foragefacebook-2.1.0/tests/fixtures/post_with_emoji.html +0 -12
- foragefacebook-2.1.0/tests/fixtures/post_with_media.html +0 -15
- foragefacebook-2.1.0/tests/fixtures/post_with_see_more.html +0 -12
- foragefacebook-2.1.0/tests/test_auth.py +0 -48
- foragefacebook-2.1.0/tests/test_cli.py +0 -161
- foragefacebook-2.1.0/tests/test_exporter.py +0 -669
- foragefacebook-2.1.0/tests/test_marketplace.py +0 -56
- foragefacebook-2.1.0/tests/test_models.py +0 -158
- foragefacebook-2.1.0/tests/test_parser.py +0 -362
- foragefacebook-2.1.0/tests/test_regressions.py +0 -352
- foragefacebook-2.1.0/tests/test_scraper.py +0 -473
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/.github/FUNDING.yml +0 -0
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/.github/dependabot.yml +0 -0
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/.github/workflows/publish.yml +0 -0
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/CLAUDE.md +0 -0
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/LICENSE +0 -0
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/SECURITY.md +0 -0
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/docs/research/2026-09-20-cli-portfolio-audit.md +0 -0
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/docs/research/2026-09-20-cli-portfolio-repairs.md +0 -0
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/scripts/release.sh +0 -0
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/src/forage/auth.py +0 -0
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/src/forage/exporter.py +0 -0
- {foragefacebook-2.1.0 → foragefacebook-2.3.0}/src/forage/models.py +0 -0
|
@@ -16,26 +16,6 @@ concurrency:
|
|
|
16
16
|
cancel-in-progress: ${{ github.ref_type != 'tag' }}
|
|
17
17
|
|
|
18
18
|
jobs:
|
|
19
|
-
test:
|
|
20
|
-
name: test (${{ matrix.python-version }})
|
|
21
|
-
runs-on: ubuntu-latest
|
|
22
|
-
timeout-minutes: 15
|
|
23
|
-
strategy:
|
|
24
|
-
fail-fast: false
|
|
25
|
-
matrix:
|
|
26
|
-
python-version: ["3.10", "3.14"]
|
|
27
|
-
steps:
|
|
28
|
-
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
29
|
-
with:
|
|
30
|
-
persist-credentials: false
|
|
31
|
-
- uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
|
32
|
-
with:
|
|
33
|
-
version: "0.12.17"
|
|
34
|
-
python-version: ${{ matrix.python-version }}
|
|
35
|
-
- run: uv sync --locked --all-extras --no-config
|
|
36
|
-
- run: uv run --locked --no-config playwright install --with-deps chromium
|
|
37
|
-
- run: uv run --locked --no-config pytest tests/ -q
|
|
38
|
-
|
|
39
19
|
quality:
|
|
40
20
|
runs-on: ubuntu-latest
|
|
41
21
|
timeout-minutes: 15
|
|
@@ -51,8 +31,8 @@ jobs:
|
|
|
51
31
|
- name: Check source and lockfile
|
|
52
32
|
run: |
|
|
53
33
|
uv lock --check --no-config
|
|
54
|
-
uv run --locked --no-config ruff check src/
|
|
55
|
-
uv run --locked --no-config ruff format --check src/
|
|
34
|
+
uv run --locked --no-config ruff check src/ scripts/
|
|
35
|
+
uv run --locked --no-config ruff format --check src/ scripts/
|
|
56
36
|
uv run --locked --no-config ty check src/forage/
|
|
57
37
|
- name: Audit workflows and release helper
|
|
58
38
|
run: |
|
|
@@ -70,12 +50,11 @@ jobs:
|
|
|
70
50
|
|
|
71
51
|
check:
|
|
72
52
|
if: always()
|
|
73
|
-
needs: [
|
|
53
|
+
needs: [quality]
|
|
74
54
|
runs-on: ubuntu-latest
|
|
75
55
|
timeout-minutes: 2
|
|
76
56
|
steps:
|
|
77
57
|
- name: Require all checks
|
|
78
58
|
env:
|
|
79
|
-
TEST_RESULT: ${{ needs.test.result }}
|
|
80
59
|
QUALITY_RESULT: ${{ needs.quality.result }}
|
|
81
|
-
run: test "$
|
|
60
|
+
run: test "$QUALITY_RESULT" = success
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
|
@@ -12,11 +12,11 @@ Forage is a CLI tool for scraping private Facebook groups to analyze community d
|
|
|
12
12
|
uv sync # Install deps (always use uv, not pip/python)
|
|
13
13
|
uv run forage --help # Run CLI
|
|
14
14
|
uv run ty check src/ # Type check (Astral's ty)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
uv run ruff check src/
|
|
19
|
-
uv run ruff format src/
|
|
15
|
+
make check # Lint, type check, and build
|
|
16
|
+
make test # Read-only live Facebook checks
|
|
17
|
+
make pre-pr # Static and live checks; set FORAGE_E2E_GROUP
|
|
18
|
+
uv run ruff check src/ scripts/ # Lint
|
|
19
|
+
uv run ruff format src/ scripts/ # Format
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
## Manual Testing
|
|
@@ -59,7 +59,12 @@ Random viewport sizes, `human_delay()` with variance, real Chromium browser, ses
|
|
|
59
59
|
|
|
60
60
|
### Test Structure
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
Use `make test` for real CLI reads through the saved Facebook session. Install Chromium first.
|
|
63
|
+
Set `FORAGE_E2E_GROUP` to test a group; otherwise the test searches Marketplace for computers.
|
|
64
|
+
Before you create a PR, run `FORAGE_E2E_GROUP=<active-group> make pre-pr`.
|
|
65
|
+
Set `FORAGE_E2E_UNTIL=YYYY-MM-DD` to also check a historical window with known posts.
|
|
66
|
+
Keep unit tests, mock tests, and fake-server suites out of this repo. CI runs static checks and package builds.
|
|
67
|
+
Live tests must not log in, change credentials, or write to Facebook.
|
|
63
68
|
|
|
64
69
|
## Fragile Code — Check Here First When Things Break
|
|
65
70
|
|
|
@@ -99,7 +104,7 @@ Steps:
|
|
|
99
104
|
1. Update `version` in both `pyproject.toml` and `src/forage/__init__.py`
|
|
100
105
|
2. Run `uv lock --no-config` — refresh locked editable package metadata without leaking user-level uv settings
|
|
101
106
|
3. Update `CHANGELOG.md` — add version section under `## [Unreleased]`, update comparison links at bottom
|
|
102
|
-
4. Run `uv lock --check --no-config` and `uv run ruff format src/
|
|
107
|
+
4. Run `uv lock --check --no-config` and `uv run ruff format src/ scripts/` — CI enforces formatting
|
|
103
108
|
5. Commit: `git commit -m "chore: release vX.Y.Z"`
|
|
104
109
|
6. Push a feature branch and open a PR. Merge only after all required checks pass. Never push directly to `master`.
|
|
105
110
|
7. Tag: `git tag -a vX.Y.Z -m "Release vX.Y.Z"` then `git push origin vX.Y.Z`
|
|
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.3.0] - 2026-11-03
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Add Marketplace sort choices and support more currency-symbol price formats, inspired by PR #50.
|
|
15
|
+
- Add local `make pre-pr` checks for live Marketplace sorts, group comments, date windows, and saved exports.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Preserve comment nesting and remove the fallback that treats arbitrary post text as comments.
|
|
20
|
+
- Read relative comment-author links and restrict permalink comments to the requested post.
|
|
21
|
+
- Continue historical searches past newer posts and include posts labeled "Just now" at the default cutoff.
|
|
22
|
+
- Parse compact reaction totals and recognize `multi_permalinks` post links.
|
|
23
|
+
- Skip repeated Marketplace candidates before text parsing and stop before an unnecessary final group scroll.
|
|
24
|
+
- Convert the CLI's miles to Facebook's kilometer radius and use the verified nearest-first sort value.
|
|
25
|
+
- Select Python 3.14 for local development, matching CI.
|
|
26
|
+
|
|
27
|
+
### Thanks
|
|
28
|
+
|
|
29
|
+
- Thanks to [@kha-faz](https://github.com/kha-faz) for the currency and Marketplace sort contributions in [PR #50](https://github.com/jwmoss/forage/pull/50).
|
|
30
|
+
|
|
31
|
+
## [2.2.0] - 2026-10-10
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- Replace unit and synthetic browser tests with read-only live CLI checks.
|
|
36
|
+
- Keep lint, type checks, and package build/install checks in CI.
|
|
37
|
+
- Remove pytest and the Node test harness.
|
|
38
|
+
- Add common `make check`, `make test`, and `make build` entry points.
|
|
39
|
+
- Add `forage version` beside the existing `--version` flag.
|
|
40
|
+
|
|
10
41
|
## [2.1.0] - 2026-09-20
|
|
11
42
|
|
|
12
43
|
### Fixed
|
|
@@ -237,7 +268,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
237
268
|
- Sensitive files excluded via `.gitignore`
|
|
238
269
|
- Security guidelines in SECURITY.md
|
|
239
270
|
|
|
240
|
-
[Unreleased]: https://github.com/jwmoss/forage/compare/v2.
|
|
271
|
+
[Unreleased]: https://github.com/jwmoss/forage/compare/v2.3.0...HEAD
|
|
272
|
+
[2.3.0]: https://github.com/jwmoss/forage/compare/v2.2.0...v2.3.0
|
|
273
|
+
[2.2.0]: https://github.com/jwmoss/forage/compare/v2.1.0...v2.2.0
|
|
241
274
|
[2.1.0]: https://github.com/jwmoss/forage/compare/v2.0.0...v2.1.0
|
|
242
275
|
[2.0.0]: https://github.com/jwmoss/forage/compare/v1.2.1...v2.0.0
|
|
243
276
|
[1.2.1]: https://github.com/jwmoss/forage/compare/v1.2.0...v1.2.1
|
|
@@ -22,7 +22,8 @@ Thank you for your interest in contributing to Forage!
|
|
|
22
22
|
|
|
23
23
|
4. Run tests:
|
|
24
24
|
```bash
|
|
25
|
-
|
|
25
|
+
make check
|
|
26
|
+
make test
|
|
26
27
|
```
|
|
27
28
|
|
|
28
29
|
5. Run type checker:
|
|
@@ -55,17 +56,26 @@ Example: `feat: add SQLite export option`
|
|
|
55
56
|
1. Fork the repository
|
|
56
57
|
2. Create a feature branch (`git checkout -b feat/my-feature`)
|
|
57
58
|
3. Make your changes
|
|
58
|
-
4.
|
|
59
|
+
4. Run the local PR checks with a saved session:
|
|
60
|
+
```bash
|
|
61
|
+
FORAGE_E2E_GROUP=your-group-slug FORAGE_E2E_UNTIL=YYYY-MM-DD make pre-pr
|
|
62
|
+
```
|
|
63
|
+
Select an active group with visible reply threads and a historical window with known posts.
|
|
64
|
+
`FORAGE_E2E_UNTIL` is optional. It tests the seven days before the selected inclusive end date.
|
|
59
65
|
5. Ensure type checking passes (`uv run ty check src/forage/`)
|
|
60
66
|
6. Commit with a descriptive message
|
|
61
67
|
7. Push and open a pull request
|
|
62
68
|
|
|
63
69
|
## Testing
|
|
64
70
|
|
|
65
|
-
-
|
|
66
|
-
-
|
|
67
|
-
-
|
|
68
|
-
-
|
|
71
|
+
- Verify behavior through the real CLI against Facebook.
|
|
72
|
+
- Keep live checks read-only and use the existing session.
|
|
73
|
+
- Do not add unit tests, mock tests, or fake-server suites.
|
|
74
|
+
- Run `make test-marketplace` to check all five sort orders against Facebook's selected controls.
|
|
75
|
+
- Run `FORAGE_E2E_GROUP=your-group-slug make test-group` to check posts, comments, and real-data exports.
|
|
76
|
+
- Run `make pre-pr` with the group variables above before you create a PR.
|
|
77
|
+
- Keep session files and private records out of commits and PR descriptions.
|
|
78
|
+
- Live checks need suitable records. A passing run does not prove rare cases that the live pages do not contain.
|
|
69
79
|
|
|
70
80
|
## Reporting Issues
|
|
71
81
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
.PHONY: check test test-marketplace test-group test-all pre-pr build
|
|
2
|
+
|
|
3
|
+
check:
|
|
4
|
+
uv lock --check --no-config
|
|
5
|
+
uv run --locked --no-config ruff check src/ scripts/
|
|
6
|
+
uv run --locked --no-config ruff format --check src/ scripts/
|
|
7
|
+
uv run --locked --no-config ty check src/forage/
|
|
8
|
+
uv build --no-config
|
|
9
|
+
|
|
10
|
+
test:
|
|
11
|
+
uv run --locked --no-config python scripts/live-e2e.py
|
|
12
|
+
|
|
13
|
+
test-marketplace:
|
|
14
|
+
env -u FORAGE_E2E_GROUP uv run --locked --no-config python scripts/live-e2e.py
|
|
15
|
+
|
|
16
|
+
test-group:
|
|
17
|
+
@test -n "$$FORAGE_E2E_GROUP" || { echo 'Set FORAGE_E2E_GROUP to an active group you can access.' >&2; exit 2; }
|
|
18
|
+
uv run --locked --no-config python scripts/live-e2e.py
|
|
19
|
+
|
|
20
|
+
test-all: test-marketplace test-group
|
|
21
|
+
|
|
22
|
+
pre-pr: check test-all
|
|
23
|
+
|
|
24
|
+
build:
|
|
25
|
+
uv build --no-config
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ForageFacebook
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: CLI tool to scrape posts, comments, and reactions from private Facebook groups
|
|
5
5
|
Project-URL: Homepage, https://github.com/jwmoss/forage
|
|
6
6
|
Project-URL: Repository, https://github.com/jwmoss/forage
|
|
@@ -28,7 +28,6 @@ Requires-Dist: playwright>=1.40
|
|
|
28
28
|
Requires-Dist: pydantic>=2.0
|
|
29
29
|
Requires-Dist: rich>=13.0
|
|
30
30
|
Provides-Extra: dev
|
|
31
|
-
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
32
31
|
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
33
32
|
Requires-Dist: ty>=0.0.1a6; extra == 'dev'
|
|
34
33
|
Description-Content-Type: text/markdown
|
|
@@ -178,11 +177,19 @@ Commands:
|
|
|
178
177
|
|
|
179
178
|
```bash
|
|
180
179
|
uv run forage marketplace "rtx 4070" --city wilmington --radius 40 --limit 20 -o listings.json
|
|
180
|
+
uv run forage marketplace "computer" --sort-by price_ascend
|
|
181
181
|
```
|
|
182
182
|
|
|
183
183
|
`--limit` counts accepted listings. `--max-candidates` bounds the search cost, including rejected candidates; its default is 200.
|
|
184
184
|
Forage excludes distant listings and listings whose coordinates it cannot verify. City is the search center, not a city-only filter.
|
|
185
|
-
The
|
|
185
|
+
The parser accepts “Free” and prices with `$`, `£`, `€`, `¥`, `₹`, `₩`, or `₽` symbols.
|
|
186
|
+
It accepts prefixes such as `CA$` and `R$`, trailing symbols such as `80 €`, and space-separated digits.
|
|
187
|
+
Prices remain display strings. Currency conversion, currency-code-only prices, and localized free labels are unsupported.
|
|
188
|
+
Search relevance follows Facebook's results.
|
|
189
|
+
|
|
190
|
+
`--sort-by` accepts `creation_time_descend` (default), `suggested`, `price_ascend`, `price_descend`, or `distance_ascend`.
|
|
191
|
+
These values request Facebook's sort order. They do not sort the collected subset locally.
|
|
192
|
+
The CLI radius uses miles; Forage converts it to kilometers for Facebook's search URL.
|
|
186
193
|
|
|
187
194
|
### Local diagnosis and saved exports
|
|
188
195
|
|
|
@@ -319,13 +326,32 @@ uv sync --extra dev
|
|
|
319
326
|
# Run type checker
|
|
320
327
|
uv run ty check src/
|
|
321
328
|
|
|
322
|
-
#
|
|
329
|
+
# Run static checks and build the package
|
|
330
|
+
make check
|
|
331
|
+
|
|
332
|
+
# Install Chromium for live reads
|
|
323
333
|
uv run playwright install chromium
|
|
324
334
|
|
|
325
|
-
#
|
|
326
|
-
|
|
335
|
+
# Read Marketplace through the saved Facebook session
|
|
336
|
+
make test
|
|
337
|
+
|
|
338
|
+
# Read posts and comments from an active group you can access
|
|
339
|
+
FORAGE_E2E_GROUP=your-group-slug make test
|
|
340
|
+
|
|
341
|
+
# Run static checks, all Marketplace sorts, group comments, and saved exports before a PR
|
|
342
|
+
FORAGE_E2E_GROUP=your-group-slug make pre-pr
|
|
343
|
+
|
|
344
|
+
# Also check a historical window with known posts (seven days through the inclusive end date)
|
|
345
|
+
FORAGE_E2E_GROUP=your-group-slug FORAGE_E2E_UNTIL=2026-10-29 make pre-pr
|
|
327
346
|
```
|
|
328
347
|
|
|
348
|
+
Live tests run the real CLI and Facebook pages. They do not use fixtures or mock servers.
|
|
349
|
+
They need an existing login session. CI runs static checks and package build/install checks.
|
|
350
|
+
Group checks require an active group with visible reply threads. The checks use temporary local files for exports.
|
|
351
|
+
Marketplace checks confirm Facebook's selected sort control for each CLI result URL.
|
|
352
|
+
Live data changes. These checks do not cover a rare parser case unless the selected pages contain it.
|
|
353
|
+
|
|
354
|
+
|
|
329
355
|
## Architecture
|
|
330
356
|
|
|
331
357
|
```
|
|
@@ -344,12 +370,12 @@ src/forage/
|
|
|
344
370
|
- Rate limiting may require slower scraping
|
|
345
371
|
- Individual reaction types not broken out (only total)
|
|
346
372
|
- Session lifetime depends on Facebook; use `forage login` when authentication fails
|
|
347
|
-
- English page labels
|
|
373
|
+
- English page labels define the supported parser scope
|
|
348
374
|
- Previously overwritten SQLite comments require a new scrape; upgrades cannot recover them
|
|
349
375
|
|
|
350
376
|
## Roadmap
|
|
351
377
|
|
|
352
|
-
Prioritize
|
|
378
|
+
Prioritize read-only live regression checks and verified selector updates. Add incremental collection only after identity and completeness checks support it.
|
|
353
379
|
Use shell loops for batches, system scheduling for recurring commands, and SQLite or jq for analysis.
|
|
354
380
|
|
|
355
381
|
### Contributing
|
|
@@ -143,11 +143,19 @@ Commands:
|
|
|
143
143
|
|
|
144
144
|
```bash
|
|
145
145
|
uv run forage marketplace "rtx 4070" --city wilmington --radius 40 --limit 20 -o listings.json
|
|
146
|
+
uv run forage marketplace "computer" --sort-by price_ascend
|
|
146
147
|
```
|
|
147
148
|
|
|
148
149
|
`--limit` counts accepted listings. `--max-candidates` bounds the search cost, including rejected candidates; its default is 200.
|
|
149
150
|
Forage excludes distant listings and listings whose coordinates it cannot verify. City is the search center, not a city-only filter.
|
|
150
|
-
The
|
|
151
|
+
The parser accepts “Free” and prices with `$`, `£`, `€`, `¥`, `₹`, `₩`, or `₽` symbols.
|
|
152
|
+
It accepts prefixes such as `CA$` and `R$`, trailing symbols such as `80 €`, and space-separated digits.
|
|
153
|
+
Prices remain display strings. Currency conversion, currency-code-only prices, and localized free labels are unsupported.
|
|
154
|
+
Search relevance follows Facebook's results.
|
|
155
|
+
|
|
156
|
+
`--sort-by` accepts `creation_time_descend` (default), `suggested`, `price_ascend`, `price_descend`, or `distance_ascend`.
|
|
157
|
+
These values request Facebook's sort order. They do not sort the collected subset locally.
|
|
158
|
+
The CLI radius uses miles; Forage converts it to kilometers for Facebook's search URL.
|
|
151
159
|
|
|
152
160
|
### Local diagnosis and saved exports
|
|
153
161
|
|
|
@@ -284,13 +292,32 @@ uv sync --extra dev
|
|
|
284
292
|
# Run type checker
|
|
285
293
|
uv run ty check src/
|
|
286
294
|
|
|
287
|
-
#
|
|
295
|
+
# Run static checks and build the package
|
|
296
|
+
make check
|
|
297
|
+
|
|
298
|
+
# Install Chromium for live reads
|
|
288
299
|
uv run playwright install chromium
|
|
289
300
|
|
|
290
|
-
#
|
|
291
|
-
|
|
301
|
+
# Read Marketplace through the saved Facebook session
|
|
302
|
+
make test
|
|
303
|
+
|
|
304
|
+
# Read posts and comments from an active group you can access
|
|
305
|
+
FORAGE_E2E_GROUP=your-group-slug make test
|
|
306
|
+
|
|
307
|
+
# Run static checks, all Marketplace sorts, group comments, and saved exports before a PR
|
|
308
|
+
FORAGE_E2E_GROUP=your-group-slug make pre-pr
|
|
309
|
+
|
|
310
|
+
# Also check a historical window with known posts (seven days through the inclusive end date)
|
|
311
|
+
FORAGE_E2E_GROUP=your-group-slug FORAGE_E2E_UNTIL=2026-10-29 make pre-pr
|
|
292
312
|
```
|
|
293
313
|
|
|
314
|
+
Live tests run the real CLI and Facebook pages. They do not use fixtures or mock servers.
|
|
315
|
+
They need an existing login session. CI runs static checks and package build/install checks.
|
|
316
|
+
Group checks require an active group with visible reply threads. The checks use temporary local files for exports.
|
|
317
|
+
Marketplace checks confirm Facebook's selected sort control for each CLI result URL.
|
|
318
|
+
Live data changes. These checks do not cover a rare parser case unless the selected pages contain it.
|
|
319
|
+
|
|
320
|
+
|
|
294
321
|
## Architecture
|
|
295
322
|
|
|
296
323
|
```
|
|
@@ -309,12 +336,12 @@ src/forage/
|
|
|
309
336
|
- Rate limiting may require slower scraping
|
|
310
337
|
- Individual reaction types not broken out (only total)
|
|
311
338
|
- Session lifetime depends on Facebook; use `forage login` when authentication fails
|
|
312
|
-
- English page labels
|
|
339
|
+
- English page labels define the supported parser scope
|
|
313
340
|
- Previously overwritten SQLite comments require a new scrape; upgrades cannot recover them
|
|
314
341
|
|
|
315
342
|
## Roadmap
|
|
316
343
|
|
|
317
|
-
Prioritize
|
|
344
|
+
Prioritize read-only live regression checks and verified selector updates. Add incremental collection only after identity and completeness checks support it.
|
|
318
345
|
Use shell loops for batches, system scheduling for recurring commands, and SQLite or jq for analysis.
|
|
319
346
|
|
|
320
347
|
### Contributing
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
# Forage capability contract
|
|
2
2
|
|
|
3
|
-
Updated: 2026-
|
|
3
|
+
Updated: 2026-11-03. This file describes observed application behavior, not exhaustive Facebook coverage.
|
|
4
4
|
|
|
5
5
|
| Surface | Verified locally | Known limits |
|
|
6
6
|
| --- | --- | --- |
|
|
7
7
|
| Login | Browser state storage with owner-only POSIX permissions | Manual Facebook login; session validity requires Facebook access |
|
|
8
8
|
| Doctor | Browser executable, session existence, permission checks | No session contents read; no browser launch; no network check |
|
|
9
|
-
| Group posts |
|
|
9
|
+
| Group posts | Live authenticated CLI reads with native post IDs and known timestamps | English labels; Facebook layout changes; media and attachment extraction absent |
|
|
10
10
|
| Dates | Inclusive calendar end dates, reversed-range rejection, relative timestamp parsing | Local system/browser timezone; month/year relative times remain approximate |
|
|
11
|
-
| Comments |
|
|
11
|
+
| Comments | Live native IDs, relative author links, and reply-parent relationships; fallback IDs scoped to posts | All-comment coverage remains unverified; identical fallback comments within one post can collide; comment timestamps remain unknown |
|
|
12
12
|
| Reactions | Supported total-count labels | Missing and skipped counts retain legacy zero defaults; no promise of complete per-type counts |
|
|
13
|
-
| Marketplace |
|
|
13
|
+
| Marketplace | Live listings and all five selected sort controls; mile-to-kilometer URL conversion; strict radius check bound to the requested listing ID | Fixed category; fuzzy Facebook relevance; approximate coordinates; unbound or invalid coordinates are excluded; no exhaustive search guarantee |
|
|
14
14
|
| Export | Group JSON to JSON, LLM JSON, CSV, SQLite without login | Keep original JSON for diagnostics; old overwritten comments cannot be recovered automatically |
|
|
15
15
|
|
|
16
16
|
## Result diagnostics
|
|
@@ -30,8 +30,19 @@ Meta's [Content Library announcement](https://about.fb.com/news/2023/11/new-tool
|
|
|
30
30
|
It does not establish private-group or general personal Marketplace API access.
|
|
31
31
|
|
|
32
32
|
The [Graph API v19 changelog](https://developers.facebook.com/docs/graph-api/changelog/version19.0/) is the authority for historical Groups API removal.
|
|
33
|
-
The
|
|
34
|
-
|
|
33
|
+
The September 2026 audit cannot retrieve that page or the [Marketplace documentation](https://developers.facebook.com/docs/marketplace/) because Meta returns 429 responses.
|
|
34
|
+
This update does not recheck replacement API availability. Do not infer private APIs from DOM fields.
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
## Local regression checks
|
|
37
|
+
|
|
38
|
+
Run `FORAGE_E2E_GROUP=<active-group> FORAGE_E2E_UNTIL=YYYY-MM-DD make pre-pr` before a PR.
|
|
39
|
+
The date is optional. Select a historical window with known posts.
|
|
40
|
+
The checks use the saved session and real CLI reads. They require visible reply threads and verify Facebook's selected sort controls,
|
|
41
|
+
post/comment records, and CSV/SQLite exports of the collected data. CI remains static and does not use Facebook credentials.
|
|
42
|
+
|
|
43
|
+
On November 3, the same historical request returns zero posts on the base revision after nine rejected newer posts.
|
|
44
|
+
The fix reaches a matching post after 63 newer candidates. Live group, export, and all five Marketplace sort checks pass.
|
|
45
|
+
Direct parser checks cover the default "Just now" cutoff, compact reaction counts, and supported currency-symbol forms.
|
|
46
|
+
Those direct checks do not establish live coverage for every rare label or locale.
|
|
47
|
+
|
|
48
|
+
Never publish session state or private group content.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ForageFacebook"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.3.0"
|
|
8
8
|
description = "CLI tool to scrape posts, comments, and reactions from private Facebook groups"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -42,7 +42,6 @@ Changelog = "https://github.com/jwmoss/forage/blob/master/CHANGELOG.md"
|
|
|
42
42
|
|
|
43
43
|
[project.optional-dependencies]
|
|
44
44
|
dev = [
|
|
45
|
-
"pytest>=8.0",
|
|
46
45
|
"ruff>=0.8",
|
|
47
46
|
"ty>=0.0.1a6",
|
|
48
47
|
]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check real Facebook reads and exports through the CLI and saved session."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import csv
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shlex
|
|
11
|
+
import sqlite3
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
from datetime import date, datetime, timedelta
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from playwright.sync_api import sync_playwright
|
|
19
|
+
|
|
20
|
+
SORT_LABELS = {
|
|
21
|
+
"creation_time_descend": "Date listed: Newest first",
|
|
22
|
+
"suggested": "Suggested",
|
|
23
|
+
"price_ascend": "Price: Lowest first",
|
|
24
|
+
"price_descend": "Price: Highest first",
|
|
25
|
+
"distance_ascend": "Distance: Nearest first",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def call(*args: str) -> dict:
|
|
30
|
+
"""Run the real CLI without exposing private records on failure."""
|
|
31
|
+
cli = shlex.split(os.environ.get("FORAGE_BIN", "forage"))
|
|
32
|
+
result = subprocess.run(
|
|
33
|
+
[*cli, "--no-color", *args], capture_output=True, text=True, timeout=300
|
|
34
|
+
)
|
|
35
|
+
if result.returncode:
|
|
36
|
+
raise RuntimeError(
|
|
37
|
+
f"{args[0]} failed with exit {result.returncode}; run the command to inspect the error"
|
|
38
|
+
)
|
|
39
|
+
return json.loads(result.stdout) if result.stdout.strip() else {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def check_group(group: str) -> None:
|
|
43
|
+
"""Check real post/comment reads, saved exports, and an optional date window."""
|
|
44
|
+
result = call("scrape", group, "--limit", "3", "--no-input")
|
|
45
|
+
posts = result["posts"]
|
|
46
|
+
assert posts and len(posts) <= 3, "No group posts or post limit exceeded"
|
|
47
|
+
assert len({post["id"] for post in posts}) == len(posts), "Duplicate posts"
|
|
48
|
+
comments = []
|
|
49
|
+
for post in posts:
|
|
50
|
+
pending = list(post["comments"])
|
|
51
|
+
seen = set()
|
|
52
|
+
while pending:
|
|
53
|
+
comment = pending.pop()
|
|
54
|
+
assert comment["id"] not in seen, "Duplicate comment or reply"
|
|
55
|
+
assert comment["content"], "Empty comment"
|
|
56
|
+
assert comment["content"] != post["content"], (
|
|
57
|
+
"Post text appears as a comment"
|
|
58
|
+
)
|
|
59
|
+
seen.add(comment["id"])
|
|
60
|
+
comments.append(comment)
|
|
61
|
+
pending.extend(comment["replies"])
|
|
62
|
+
assert comments, "Use an active group with visible comments for this check"
|
|
63
|
+
assert any(c["replies"] for c in comments), "Use a group with visible reply threads"
|
|
64
|
+
assert any(c["author"]["name"] != "Unknown" for c in comments), "No comment authors"
|
|
65
|
+
|
|
66
|
+
with tempfile.TemporaryDirectory(prefix="forage-live-") as directory:
|
|
67
|
+
root = Path(directory)
|
|
68
|
+
source = root / "posts.json"
|
|
69
|
+
source.write_text(json.dumps(result), encoding="utf-8")
|
|
70
|
+
call("export", str(source), "--format", "csv", "-o", str(root / "posts.csv"))
|
|
71
|
+
with (root / "posts.csv").open() as handle:
|
|
72
|
+
assert len(list(csv.DictReader(handle))) == len(posts), "CSV loses posts"
|
|
73
|
+
with (root / "posts.comments.csv").open() as handle:
|
|
74
|
+
assert len(list(csv.DictReader(handle))) == len(comments), (
|
|
75
|
+
"CSV loses comments"
|
|
76
|
+
)
|
|
77
|
+
call("export", str(source), "--format", "sqlite", "-o", str(root / "posts.db"))
|
|
78
|
+
with sqlite3.connect(root / "posts.db") as db:
|
|
79
|
+
assert db.execute("SELECT COUNT(*) FROM posts").fetchone()[0] == len(posts)
|
|
80
|
+
assert db.execute("SELECT COUNT(*) FROM comments").fetchone()[0] == len(
|
|
81
|
+
comments
|
|
82
|
+
)
|
|
83
|
+
print(
|
|
84
|
+
"PASS live group posts, comments, replies, and CSV/SQLite exports", flush=True
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
until = os.environ.get("FORAGE_E2E_UNTIL")
|
|
88
|
+
if until:
|
|
89
|
+
end = date.fromisoformat(until)
|
|
90
|
+
start = end - timedelta(days=7)
|
|
91
|
+
history = call(
|
|
92
|
+
"scrape",
|
|
93
|
+
group,
|
|
94
|
+
"--limit",
|
|
95
|
+
"1",
|
|
96
|
+
"--skip-comments",
|
|
97
|
+
"--no-input",
|
|
98
|
+
"--since",
|
|
99
|
+
start.isoformat(),
|
|
100
|
+
"--until",
|
|
101
|
+
until,
|
|
102
|
+
)
|
|
103
|
+
assert history["posts"], "No historical record; use a window with known posts"
|
|
104
|
+
for post in history["posts"]:
|
|
105
|
+
assert post["timestamp"], "Historical check needs a known timestamp"
|
|
106
|
+
assert start <= datetime.fromisoformat(post["timestamp"]).date() <= end
|
|
107
|
+
print("PASS live historical date window", flush=True)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def check_marketplace(session_path: str) -> None:
|
|
111
|
+
"""Check each sort through the CLI and Facebook's selected UI control."""
|
|
112
|
+
with sync_playwright() as playwright:
|
|
113
|
+
browser = playwright.chromium.launch()
|
|
114
|
+
page = browser.new_context(storage_state=session_path).new_page()
|
|
115
|
+
try:
|
|
116
|
+
for order, label in SORT_LABELS.items():
|
|
117
|
+
result = call(
|
|
118
|
+
"marketplace",
|
|
119
|
+
"computer",
|
|
120
|
+
"--limit",
|
|
121
|
+
"1",
|
|
122
|
+
"--max-candidates",
|
|
123
|
+
"10",
|
|
124
|
+
"--sort-by",
|
|
125
|
+
order,
|
|
126
|
+
)
|
|
127
|
+
assert len(result["listings"]) == 1, "No accepted Marketplace listing"
|
|
128
|
+
assert result["listings"][0]["id"], "Missing listing ID"
|
|
129
|
+
assert result["diagnostics"]["candidates_seen"] <= 10, (
|
|
130
|
+
"Candidate limit exceeded"
|
|
131
|
+
)
|
|
132
|
+
page.goto(result["search_url"], wait_until="domcontentloaded")
|
|
133
|
+
page.wait_for_selector('a[href*="/marketplace/item/"]')
|
|
134
|
+
radio = page.get_by_role("radio", name=label, exact=True)
|
|
135
|
+
if not radio.count():
|
|
136
|
+
page.get_by_role("button", name=re.compile(r"^Sort by")).click()
|
|
137
|
+
assert radio.get_attribute("aria-checked") == "true", (
|
|
138
|
+
f"Facebook ignores {order}"
|
|
139
|
+
)
|
|
140
|
+
print(f"PASS live Marketplace {order}", flush=True)
|
|
141
|
+
finally:
|
|
142
|
+
browser.close()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def main() -> None:
|
|
146
|
+
"""Verify the local setup, then read the selected live Facebook surface."""
|
|
147
|
+
doctor = call("doctor")
|
|
148
|
+
if not doctor.get("browser_installed") or not doctor.get("session_exists"):
|
|
149
|
+
raise RuntimeError(
|
|
150
|
+
"Install Chromium and save a Facebook session before live tests"
|
|
151
|
+
)
|
|
152
|
+
group = os.environ.get("FORAGE_E2E_GROUP")
|
|
153
|
+
if group:
|
|
154
|
+
check_group(group)
|
|
155
|
+
else:
|
|
156
|
+
check_marketplace(doctor["session_path"])
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
if __name__ == "__main__":
|
|
160
|
+
try:
|
|
161
|
+
main()
|
|
162
|
+
except Exception as error:
|
|
163
|
+
print(f"FAIL {type(error).__name__}: {error}", file=sys.stderr)
|
|
164
|
+
sys.exit(1)
|