ForageFacebook 2.2.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.3.0/.python-version +1 -0
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/AGENTS.md +3 -0
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/CHANGELOG.md +23 -1
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/CONTRIBUTING.md +11 -3
- foragefacebook-2.3.0/Makefile +25 -0
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/PKG-INFO +21 -4
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/README.md +20 -3
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/docs/capabilities.md +19 -8
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/pyproject.toml +1 -1
- foragefacebook-2.3.0/scripts/live-e2e.py +164 -0
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/src/forage/__init__.py +1 -1
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/src/forage/cli.py +19 -1
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/src/forage/parser.py +47 -26
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/src/forage/scraper.py +144 -189
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/uv.lock +1 -1
- foragefacebook-2.2.0/Makefile +0 -14
- foragefacebook-2.2.0/scripts/live-e2e.py +0 -57
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/.github/FUNDING.yml +0 -0
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/.github/dependabot.yml +0 -0
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/.github/workflows/ci.yml +0 -0
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/.github/workflows/publish.yml +0 -0
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/.gitignore +0 -0
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/CLAUDE.md +0 -0
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/LICENSE +0 -0
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/SECURITY.md +0 -0
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/docs/research/2026-09-20-cli-portfolio-audit.md +0 -0
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/docs/research/2026-09-20-cli-portfolio-repairs.md +0 -0
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/scripts/release.sh +0 -0
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/src/forage/auth.py +0 -0
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/src/forage/exporter.py +0 -0
- {foragefacebook-2.2.0 → foragefacebook-2.3.0}/src/forage/models.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
|
@@ -14,6 +14,7 @@ uv run forage --help # Run CLI
|
|
|
14
14
|
uv run ty check src/ # Type check (Astral's ty)
|
|
15
15
|
make check # Lint, type check, and build
|
|
16
16
|
make test # Read-only live Facebook checks
|
|
17
|
+
make pre-pr # Static and live checks; set FORAGE_E2E_GROUP
|
|
17
18
|
uv run ruff check src/ scripts/ # Lint
|
|
18
19
|
uv run ruff format src/ scripts/ # Format
|
|
19
20
|
```
|
|
@@ -60,6 +61,8 @@ Random viewport sizes, `human_delay()` with variance, real Chromium browser, ses
|
|
|
60
61
|
|
|
61
62
|
Use `make test` for real CLI reads through the saved Facebook session. Install Chromium first.
|
|
62
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.
|
|
63
66
|
Keep unit tests, mock tests, and fake-server suites out of this repo. CI runs static checks and package builds.
|
|
64
67
|
Live tests must not log in, change credentials, or write to Facebook.
|
|
65
68
|
|
|
@@ -7,6 +7,27 @@ 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
|
+
|
|
10
31
|
## [2.2.0] - 2026-10-10
|
|
11
32
|
|
|
12
33
|
### Changed
|
|
@@ -247,7 +268,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
247
268
|
- Sensitive files excluded via `.gitignore`
|
|
248
269
|
- Security guidelines in SECURITY.md
|
|
249
270
|
|
|
250
|
-
[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
|
|
251
273
|
[2.2.0]: https://github.com/jwmoss/forage/compare/v2.1.0...v2.2.0
|
|
252
274
|
[2.1.0]: https://github.com/jwmoss/forage/compare/v2.0.0...v2.1.0
|
|
253
275
|
[2.0.0]: https://github.com/jwmoss/forage/compare/v1.2.1...v2.0.0
|
|
@@ -56,7 +56,12 @@ Example: `feat: add SQLite export option`
|
|
|
56
56
|
1. Fork the repository
|
|
57
57
|
2. Create a feature branch (`git checkout -b feat/my-feature`)
|
|
58
58
|
3. Make your changes
|
|
59
|
-
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.
|
|
60
65
|
5. Ensure type checking passes (`uv run ty check src/forage/`)
|
|
61
66
|
6. Commit with a descriptive message
|
|
62
67
|
7. Push and open a pull request
|
|
@@ -66,8 +71,11 @@ Example: `feat: add SQLite export option`
|
|
|
66
71
|
- Verify behavior through the real CLI against Facebook.
|
|
67
72
|
- Keep live checks read-only and use the existing session.
|
|
68
73
|
- Do not add unit tests, mock tests, or fake-server suites.
|
|
69
|
-
-
|
|
70
|
-
-
|
|
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.
|
|
71
79
|
|
|
72
80
|
## Reporting Issues
|
|
73
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
|
|
@@ -177,11 +177,19 @@ Commands:
|
|
|
177
177
|
|
|
178
178
|
```bash
|
|
179
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
|
|
180
181
|
```
|
|
181
182
|
|
|
182
183
|
`--limit` counts accepted listings. `--max-candidates` bounds the search cost, including rejected candidates; its default is 200.
|
|
183
184
|
Forage excludes distant listings and listings whose coordinates it cannot verify. City is the search center, not a city-only filter.
|
|
184
|
-
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.
|
|
185
193
|
|
|
186
194
|
### Local diagnosis and saved exports
|
|
187
195
|
|
|
@@ -327,12 +335,21 @@ uv run playwright install chromium
|
|
|
327
335
|
# Read Marketplace through the saved Facebook session
|
|
328
336
|
make test
|
|
329
337
|
|
|
330
|
-
# Read
|
|
338
|
+
# Read posts and comments from an active group you can access
|
|
331
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
|
|
332
346
|
```
|
|
333
347
|
|
|
334
348
|
Live tests run the real CLI and Facebook pages. They do not use fixtures or mock servers.
|
|
335
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.
|
|
336
353
|
|
|
337
354
|
|
|
338
355
|
## Architecture
|
|
@@ -358,7 +375,7 @@ src/forage/
|
|
|
358
375
|
|
|
359
376
|
## Roadmap
|
|
360
377
|
|
|
361
|
-
Prioritize
|
|
378
|
+
Prioritize read-only live regression checks and verified selector updates. Add incremental collection only after identity and completeness checks support it.
|
|
362
379
|
Use shell loops for batches, system scheduling for recurring commands, and SQLite or jq for analysis.
|
|
363
380
|
|
|
364
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
|
|
|
@@ -293,12 +301,21 @@ uv run playwright install chromium
|
|
|
293
301
|
# Read Marketplace through the saved Facebook session
|
|
294
302
|
make test
|
|
295
303
|
|
|
296
|
-
# Read
|
|
304
|
+
# Read posts and comments from an active group you can access
|
|
297
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
|
|
298
312
|
```
|
|
299
313
|
|
|
300
314
|
Live tests run the real CLI and Facebook pages. They do not use fixtures or mock servers.
|
|
301
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.
|
|
302
319
|
|
|
303
320
|
|
|
304
321
|
## Architecture
|
|
@@ -324,7 +341,7 @@ src/forage/
|
|
|
324
341
|
|
|
325
342
|
## Roadmap
|
|
326
343
|
|
|
327
|
-
Prioritize
|
|
344
|
+
Prioritize read-only live regression checks and verified selector updates. Add incremental collection only after identity and completeness checks support it.
|
|
328
345
|
Use shell loops for batches, system scheduling for recurring commands, and SQLite or jq for analysis.
|
|
329
346
|
|
|
330
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.
|
|
@@ -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)
|
|
@@ -121,6 +121,22 @@ def login(ctx: Context, browser: str, session_dir: Optional[Path]):
|
|
|
121
121
|
help="Stop after this many candidates, including radius exclusions",
|
|
122
122
|
)
|
|
123
123
|
@click.option("-o", "--output", type=click.Path(path_type=Path), default=None)
|
|
124
|
+
@click.option(
|
|
125
|
+
"--sort-by",
|
|
126
|
+
type=click.Choice(
|
|
127
|
+
[
|
|
128
|
+
"suggested",
|
|
129
|
+
"creation_time_descend",
|
|
130
|
+
"price_ascend",
|
|
131
|
+
"price_descend",
|
|
132
|
+
"distance_ascend",
|
|
133
|
+
],
|
|
134
|
+
case_sensitive=False,
|
|
135
|
+
),
|
|
136
|
+
default="creation_time_descend",
|
|
137
|
+
show_default=True,
|
|
138
|
+
help="Requested Marketplace sort order; Facebook controls the results",
|
|
139
|
+
)
|
|
124
140
|
@click.option("--session-dir", type=click.Path(path_type=Path), default=None)
|
|
125
141
|
@click.option("--headless/--no-headless", default=True)
|
|
126
142
|
@click.option(
|
|
@@ -137,12 +153,13 @@ def marketplace(
|
|
|
137
153
|
radius: str,
|
|
138
154
|
limit: int,
|
|
139
155
|
max_candidates: int,
|
|
156
|
+
sort_by: str,
|
|
140
157
|
output: Optional[Path],
|
|
141
158
|
session_dir: Optional[Path],
|
|
142
159
|
headless: bool,
|
|
143
160
|
browser: str,
|
|
144
161
|
) -> None:
|
|
145
|
-
"""Search Marketplace electronics listings
|
|
162
|
+
"""Search Marketplace electronics listings (newest first by default)."""
|
|
146
163
|
if not session_exists(session_dir):
|
|
147
164
|
console.print("[red]Please run 'forage login' first.[/red]")
|
|
148
165
|
raise SystemExit(3)
|
|
@@ -152,6 +169,7 @@ def marketplace(
|
|
|
152
169
|
radius=int(radius),
|
|
153
170
|
limit=limit,
|
|
154
171
|
max_candidates=max_candidates,
|
|
172
|
+
sort_by=sort_by,
|
|
155
173
|
headless=headless,
|
|
156
174
|
verbose=ctx.verbose,
|
|
157
175
|
session_dir=session_dir,
|
|
@@ -21,6 +21,14 @@ from forage.models import (
|
|
|
21
21
|
|
|
22
22
|
console = Console(stderr=True)
|
|
23
23
|
|
|
24
|
+
# Keep prices as displayed; this recognizes symbols, not currency identities.
|
|
25
|
+
_PRICE_NUMBER = r"\d(?:[\d., \u00a0\u202f]*\d)?"
|
|
26
|
+
_PRICE_SYMBOL = r"(?:[A-Z]{1,3})?[$£€¥₹₩₽]"
|
|
27
|
+
_PRICE_RE = re.compile(
|
|
28
|
+
rf"^(?:free|{_PRICE_SYMBOL}\s*{_PRICE_NUMBER}|{_PRICE_NUMBER}\s*{_PRICE_SYMBOL})$",
|
|
29
|
+
re.IGNORECASE,
|
|
30
|
+
)
|
|
31
|
+
|
|
24
32
|
|
|
25
33
|
def _warn_parse_failure(kind: str, error: Exception, *, verbose: bool) -> None:
|
|
26
34
|
"""Surface a parse exception instead of silently dropping the element."""
|
|
@@ -227,6 +235,10 @@ def extract_post_id(url: str) -> Optional[str]:
|
|
|
227
235
|
|
|
228
236
|
parsed = urlparse(url)
|
|
229
237
|
|
|
238
|
+
multi_id = parse_qs(parsed.query).get("multi_permalinks", [None])[0]
|
|
239
|
+
if multi_id and multi_id.isdigit():
|
|
240
|
+
return multi_id
|
|
241
|
+
|
|
230
242
|
if "story_fbid" in url:
|
|
231
243
|
params = parse_qs(parsed.query)
|
|
232
244
|
story_fbid = params.get("story_fbid", [None])[0]
|
|
@@ -339,7 +351,7 @@ def extract_post_identity(
|
|
|
339
351
|
) -> tuple[Optional[str], Optional[str]]:
|
|
340
352
|
"""Return this article's reliable Facebook permalink identity, if available."""
|
|
341
353
|
for link in article.query_selector_all(
|
|
342
|
-
'a[href*="/posts/"], a[href*="story_fbid="]'
|
|
354
|
+
'a[href*="/posts/"], a[href*="story_fbid="], a[href*="multi_permalinks="]'
|
|
343
355
|
):
|
|
344
356
|
href = link.get_attribute("href") or ""
|
|
345
357
|
if "comment_id=" in href:
|
|
@@ -465,7 +477,7 @@ def parse_modern_post(
|
|
|
465
477
|
|
|
466
478
|
# Find timestamp - look for aria-label with time info or links with timestamps
|
|
467
479
|
time_links = article.query_selector_all(
|
|
468
|
-
'a[href*="/posts/"], a[href*="
|
|
480
|
+
'a[href*="/posts/"], a[href*="story_fbid="], a[href*="multi_permalinks="]'
|
|
469
481
|
)
|
|
470
482
|
timestamp = _parse_post_timestamp(time_links, now=now)
|
|
471
483
|
|
|
@@ -499,9 +511,9 @@ def parse_modern_post(
|
|
|
499
511
|
# Try finding reaction count in text like "All reactions:\n44"
|
|
500
512
|
if reactions.total == 0:
|
|
501
513
|
# Look for "All reactions:" followed by a number
|
|
502
|
-
match = re.search(r"All reactions:?\s
|
|
514
|
+
match = re.search(r"All reactions:?\s*([\d,.]+\s*[kKmM]?)", all_text)
|
|
503
515
|
if match:
|
|
504
|
-
reactions = Reactions(total=
|
|
516
|
+
reactions = Reactions(total=_parse_compact_int(match.group(1)))
|
|
505
517
|
|
|
506
518
|
# Also try just standalone numbers near "reactions" or after names
|
|
507
519
|
if reactions.total == 0:
|
|
@@ -550,6 +562,25 @@ def parse_modern_post(
|
|
|
550
562
|
return None
|
|
551
563
|
|
|
552
564
|
|
|
565
|
+
def extract_comment_identity(
|
|
566
|
+
element: ElementHandle,
|
|
567
|
+
) -> tuple[Optional[str], Optional[str]]:
|
|
568
|
+
"""Return a comment's native ID and its native reply-parent ID."""
|
|
569
|
+
for link in element.query_selector_all('a[href*="comment_id="]'):
|
|
570
|
+
if not link.evaluate(
|
|
571
|
+
"(node, root) => node.closest('[role=article]') === root", element
|
|
572
|
+
):
|
|
573
|
+
continue
|
|
574
|
+
params = parse_qs(urlparse(link.get_attribute("href") or "").query)
|
|
575
|
+
parent_id = params.get("comment_id", [None])[0]
|
|
576
|
+
reply_id = params.get("reply_comment_id", [None])[0]
|
|
577
|
+
if reply_id:
|
|
578
|
+
return reply_id, parent_id
|
|
579
|
+
if parent_id:
|
|
580
|
+
return parent_id, None
|
|
581
|
+
return None, None
|
|
582
|
+
|
|
583
|
+
|
|
553
584
|
def parse_modern_comment(
|
|
554
585
|
element: ElementHandle,
|
|
555
586
|
*,
|
|
@@ -576,36 +607,30 @@ def parse_modern_comment(
|
|
|
576
607
|
# Try to find profile link
|
|
577
608
|
links = element.query_selector_all('a[role="link"]')
|
|
578
609
|
for link in links:
|
|
610
|
+
if not link.evaluate(
|
|
611
|
+
"(node, root) => node.closest('[role=article]') === root", element
|
|
612
|
+
):
|
|
613
|
+
continue
|
|
579
614
|
href = link.get_attribute("href") or ""
|
|
580
615
|
text = link.inner_text().strip()
|
|
616
|
+
profile = urlparse(urljoin("https://www.facebook.com", href))
|
|
581
617
|
if (
|
|
582
618
|
text
|
|
583
619
|
and len(text) < 50
|
|
584
|
-
and "facebook.com
|
|
585
|
-
and "/groups/" not in
|
|
620
|
+
and profile.hostname in {"facebook.com", "www.facebook.com"}
|
|
621
|
+
and ("/user/" in profile.path or "/groups/" not in profile.path)
|
|
622
|
+
and "comment_id=" not in href
|
|
586
623
|
):
|
|
587
624
|
if author_name == "Unknown":
|
|
588
625
|
author_name = text
|
|
589
|
-
profile_url =
|
|
626
|
+
profile_url = profile.geturl()
|
|
590
627
|
break
|
|
591
628
|
|
|
592
629
|
content, _ = _article_content(element, author_name)
|
|
593
630
|
if not content:
|
|
594
631
|
return None
|
|
595
632
|
|
|
596
|
-
comment_id =
|
|
597
|
-
for link in element.query_selector_all('a[href*="comment_id="]'):
|
|
598
|
-
if not link.evaluate(
|
|
599
|
-
"(node, root) => node.closest('[role=article]') === root.closest('[role=article]')",
|
|
600
|
-
element,
|
|
601
|
-
):
|
|
602
|
-
continue
|
|
603
|
-
params = parse_qs(urlparse(link.get_attribute("href") or "").query)
|
|
604
|
-
comment_id = (
|
|
605
|
-
params.get("reply_comment_id") or params.get("comment_id") or [None]
|
|
606
|
-
)[0]
|
|
607
|
-
if comment_id:
|
|
608
|
-
break
|
|
633
|
+
comment_id, _ = extract_comment_identity(element)
|
|
609
634
|
comment_id = comment_id or _stable_id(
|
|
610
635
|
"comment", post_id, author_name, profile_url or "", content
|
|
611
636
|
)
|
|
@@ -659,18 +684,14 @@ def parse_marketplace_listing(
|
|
|
659
684
|
line.strip() for line in element.inner_text().splitlines() if line.strip()
|
|
660
685
|
]
|
|
661
686
|
price_index = next(
|
|
662
|
-
(
|
|
663
|
-
index
|
|
664
|
-
for index, line in enumerate(lines)
|
|
665
|
-
if line.lower() == "free" or line.startswith("$")
|
|
666
|
-
),
|
|
687
|
+
(index for index, line in enumerate(lines) if _PRICE_RE.fullmatch(line)),
|
|
667
688
|
None,
|
|
668
689
|
)
|
|
669
690
|
if price_index is None or price_index + 1 >= len(lines):
|
|
670
691
|
return None
|
|
671
692
|
|
|
672
693
|
details = lines[price_index + 1 :]
|
|
673
|
-
while details and details[0]
|
|
694
|
+
while details and _PRICE_RE.fullmatch(details[0]):
|
|
674
695
|
details.pop(0)
|
|
675
696
|
if not details:
|
|
676
697
|
return None
|
|
@@ -38,6 +38,8 @@ from forage.models import (
|
|
|
38
38
|
from forage.parser import (
|
|
39
39
|
filter_comments,
|
|
40
40
|
extract_post_identity,
|
|
41
|
+
extract_post_id,
|
|
42
|
+
extract_comment_identity,
|
|
41
43
|
parse_marketplace_listing,
|
|
42
44
|
parse_modern_post,
|
|
43
45
|
parse_modern_comment,
|
|
@@ -45,6 +47,11 @@ from forage.parser import (
|
|
|
45
47
|
|
|
46
48
|
console = Console(stderr=True)
|
|
47
49
|
|
|
50
|
+
COMMENT_ARTICLES = (
|
|
51
|
+
'[role="article"][aria-label^="Comment by"], '
|
|
52
|
+
'[role="article"][aria-label^="Reply by"]'
|
|
53
|
+
)
|
|
54
|
+
|
|
48
55
|
MARKETPLACE_CENTER_PATTERN = re.compile(
|
|
49
56
|
r'"buyLocation"\s*:\s*\{\s*"latitude"\s*:\s*(-?\d+(?:\.\d+)?),'
|
|
50
57
|
r'\s*"longitude"\s*:\s*(-?\d+(?:\.\d+)?)'
|
|
@@ -121,6 +128,7 @@ class MarketplaceOptions:
|
|
|
121
128
|
verbose: bool = False
|
|
122
129
|
session_dir: Optional[Path] = None
|
|
123
130
|
browser_type: str = "chromium"
|
|
131
|
+
sort_by: str = "creation_time_descend"
|
|
124
132
|
|
|
125
133
|
|
|
126
134
|
def random_delay(base: float = 1.0, variance: float = 0.5) -> float:
|
|
@@ -166,14 +174,17 @@ def get_group_url(group_id: str) -> str:
|
|
|
166
174
|
return f"https://www.facebook.com/groups/{group_id}?sorting_setting=CHRONOLOGICAL"
|
|
167
175
|
|
|
168
176
|
|
|
169
|
-
def get_marketplace_url(
|
|
170
|
-
|
|
177
|
+
def get_marketplace_url(
|
|
178
|
+
query: str, city: str, radius: int, sort_by: str = "creation_time_descend"
|
|
179
|
+
) -> str:
|
|
180
|
+
"""Build a Marketplace electronics search URL with the requested order."""
|
|
171
181
|
city_slug = city.strip().strip("/")
|
|
172
182
|
params = urlencode(
|
|
173
183
|
{
|
|
174
184
|
"query": query,
|
|
175
|
-
|
|
176
|
-
"
|
|
185
|
+
# Facebook's URL uses kilometers; the CLI and distance check use miles.
|
|
186
|
+
"radius": round(radius * 1.609344),
|
|
187
|
+
"sortBy": sort_by,
|
|
177
188
|
"category": "electronics",
|
|
178
189
|
}
|
|
179
190
|
)
|
|
@@ -264,7 +275,8 @@ def calculate_date_range(
|
|
|
264
275
|
date.fromisoformat(options.until), datetime_time()
|
|
265
276
|
) + timedelta(days=1)
|
|
266
277
|
else:
|
|
267
|
-
|
|
278
|
+
# Include posts labeled "Just now" in the default, exclusive cutoff.
|
|
279
|
+
until_date = now + timedelta(microseconds=1)
|
|
268
280
|
|
|
269
281
|
if options.since:
|
|
270
282
|
since_date = datetime.combine(
|
|
@@ -305,19 +317,8 @@ def create_browser_context(
|
|
|
305
317
|
return context
|
|
306
318
|
|
|
307
319
|
|
|
308
|
-
def _find_comment_articles(container: Page | ElementHandle) -> list[ElementHandle]:
|
|
309
|
-
"""Find likely comment article elements inside a page or post container."""
|
|
310
|
-
comment_articles = container.query_selector_all(
|
|
311
|
-
'[role="article"][aria-label^="Comment by"]'
|
|
312
|
-
)
|
|
313
|
-
if comment_articles:
|
|
314
|
-
return comment_articles
|
|
315
|
-
|
|
316
|
-
return container.query_selector_all('[role="article"]')
|
|
317
|
-
|
|
318
|
-
|
|
319
320
|
def _has_comment_aria(container: Page | ElementHandle) -> bool:
|
|
320
|
-
return bool(container.query_selector(
|
|
321
|
+
return bool(container.query_selector(COMMENT_ARTICLES))
|
|
321
322
|
|
|
322
323
|
|
|
323
324
|
def _comment_article_depth(element: ElementHandle, selector: str) -> int:
|
|
@@ -345,105 +346,105 @@ def _comment_article_depth(element: ElementHandle, selector: str) -> int:
|
|
|
345
346
|
return -1
|
|
346
347
|
|
|
347
348
|
|
|
348
|
-
def
|
|
349
|
-
|
|
350
|
-
article: ElementHandle,
|
|
351
|
-
options: ScrapeOptions,
|
|
352
|
-
*,
|
|
353
|
-
post_id: str = "",
|
|
349
|
+
def _collect_comments(
|
|
350
|
+
container: Page | ElementHandle, options: ScrapeOptions, post_id: str
|
|
354
351
|
) -> list[Comment]:
|
|
355
|
-
"""
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
try:
|
|
367
|
-
# Find all comment containers
|
|
368
|
-
# Modern Facebook comments are in divs with specific structure
|
|
369
|
-
comment_elements = _find_comment_articles(article)
|
|
370
|
-
|
|
371
|
-
if not comment_elements:
|
|
372
|
-
# Try alternative selector for comments
|
|
373
|
-
# Comments often have a specific nesting pattern
|
|
374
|
-
all_text = article.inner_text()
|
|
375
|
-
if "Comment" in all_text or "Reply" in all_text:
|
|
376
|
-
# There might be comments but in a different structure
|
|
377
|
-
# Try to find elements that look like comments
|
|
378
|
-
potential_comments = article.query_selector_all('div[dir="auto"]')
|
|
379
|
-
for elem in potential_comments:
|
|
380
|
-
comment = parse_modern_comment(
|
|
381
|
-
elem,
|
|
382
|
-
skip_reactions=options.skip_reactions,
|
|
383
|
-
verbose=options.verbose,
|
|
384
|
-
post_id=post_id,
|
|
385
|
-
)
|
|
386
|
-
if (
|
|
387
|
-
comment
|
|
388
|
-
and comment.content
|
|
389
|
-
and comment.id not in seen_comment_ids
|
|
390
|
-
):
|
|
391
|
-
comments.append(comment)
|
|
392
|
-
seen_comment_ids.add(comment.id)
|
|
393
|
-
|
|
394
|
-
for elem in comment_elements:
|
|
395
|
-
comment = parse_modern_comment(
|
|
396
|
-
elem,
|
|
397
|
-
skip_reactions=options.skip_reactions,
|
|
398
|
-
verbose=options.verbose,
|
|
399
|
-
post_id=post_id,
|
|
400
|
-
)
|
|
401
|
-
if comment and comment.content and comment.id not in seen_comment_ids:
|
|
402
|
-
comments.append(comment)
|
|
403
|
-
seen_comment_ids.add(comment.id)
|
|
404
|
-
|
|
405
|
-
# Try clicking "View more comments" links
|
|
406
|
-
view_more_selectors = [
|
|
407
|
-
'span:has-text("View more comments")',
|
|
408
|
-
'span:has-text("View all")',
|
|
409
|
-
'[role="button"]:has-text("comments")',
|
|
410
|
-
]
|
|
411
|
-
|
|
412
|
-
for selector in view_more_selectors:
|
|
413
|
-
try:
|
|
414
|
-
view_more = article.query_selector(selector)
|
|
415
|
-
if view_more:
|
|
416
|
-
view_more.click()
|
|
417
|
-
human_delay(page, options.delay, options.delay * 0.3)
|
|
418
|
-
break
|
|
419
|
-
except Exception:
|
|
352
|
+
"""Parse article-backed comments and preserve their nesting."""
|
|
353
|
+
labeled = _has_comment_aria(container)
|
|
354
|
+
selector = COMMENT_ARTICLES if labeled else '[role="article"]'
|
|
355
|
+
seen: dict[str, Comment] = {}
|
|
356
|
+
replies: list[tuple[Comment, str]] = []
|
|
357
|
+
|
|
358
|
+
def collect(parent: Page | ElementHandle, depth: int) -> list[Comment]:
|
|
359
|
+
comments: list[Comment] = []
|
|
360
|
+
for element in parent.query_selector_all(selector):
|
|
361
|
+
if _comment_article_depth(element, selector) != depth:
|
|
420
362
|
continue
|
|
421
|
-
|
|
422
|
-
# After expanding, try to get more comments
|
|
423
|
-
expanded_comments = _find_comment_articles(article)
|
|
424
|
-
for elem in expanded_comments:
|
|
425
363
|
comment = parse_modern_comment(
|
|
426
|
-
|
|
364
|
+
element,
|
|
427
365
|
skip_reactions=options.skip_reactions,
|
|
428
366
|
verbose=options.verbose,
|
|
429
367
|
post_id=post_id,
|
|
430
368
|
)
|
|
431
|
-
if comment
|
|
369
|
+
if comment is None or comment.id in seen:
|
|
370
|
+
continue
|
|
371
|
+
seen[comment.id] = comment
|
|
372
|
+
comment.replies = collect(element, depth + 1)
|
|
373
|
+
_, parent_id = extract_comment_identity(element)
|
|
374
|
+
if parent_id:
|
|
375
|
+
replies.append((comment, parent_id))
|
|
376
|
+
else:
|
|
432
377
|
comments.append(comment)
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
except Exception as e:
|
|
436
|
-
if options.verbose:
|
|
437
|
-
console.print(f"[yellow]Warning: Error scraping comments: {e}[/yellow]")
|
|
378
|
+
return comments
|
|
438
379
|
|
|
439
|
-
|
|
440
|
-
|
|
380
|
+
comments = collect(container, 0 if labeled else 1)
|
|
381
|
+
for reply, parent_id in replies:
|
|
382
|
+
if parent_id in seen and parent_id != reply.id:
|
|
383
|
+
seen[parent_id].replies.append(reply)
|
|
384
|
+
return filter_comments(
|
|
441
385
|
comments,
|
|
442
386
|
min_reactions=options.min_reactions,
|
|
443
387
|
top_n=options.top_comments,
|
|
444
388
|
)
|
|
445
389
|
|
|
446
|
-
|
|
390
|
+
|
|
391
|
+
def _find_post_container(page: Page, post_id: str) -> Optional[ElementHandle]:
|
|
392
|
+
"""Find the requested post's dialog or article without unrelated feed posts."""
|
|
393
|
+
for dialog in page.query_selector_all('[role="dialog"]'):
|
|
394
|
+
for link in dialog.query_selector_all("a[href]"):
|
|
395
|
+
if extract_post_id(link.get_attribute("href") or "") == post_id:
|
|
396
|
+
return dialog
|
|
397
|
+
for article in page.query_selector_all('[role="article"]'):
|
|
398
|
+
if extract_post_identity(article)[0] == post_id:
|
|
399
|
+
return article
|
|
400
|
+
return None
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def scrape_post_comments(
|
|
404
|
+
page: Page,
|
|
405
|
+
article: ElementHandle,
|
|
406
|
+
options: ScrapeOptions,
|
|
407
|
+
*,
|
|
408
|
+
post_id: str = "",
|
|
409
|
+
) -> list[Comment]:
|
|
410
|
+
"""Expand visible feed comments, then parse their article hierarchy."""
|
|
411
|
+
if options.skip_comments:
|
|
412
|
+
return []
|
|
413
|
+
|
|
414
|
+
for selector in (
|
|
415
|
+
'span:has-text("View more comments")',
|
|
416
|
+
'span:has-text("View all")',
|
|
417
|
+
'[role="button"]:has-text("comments")',
|
|
418
|
+
):
|
|
419
|
+
try:
|
|
420
|
+
view_more = article.query_selector(selector)
|
|
421
|
+
if view_more:
|
|
422
|
+
view_more.click(timeout=2000)
|
|
423
|
+
human_delay(page, options.delay, options.delay * 0.3)
|
|
424
|
+
break
|
|
425
|
+
except Exception:
|
|
426
|
+
continue
|
|
427
|
+
|
|
428
|
+
try:
|
|
429
|
+
container = _find_post_container(page, post_id) or article
|
|
430
|
+
for control in container.query_selector_all('[role="button"]'):
|
|
431
|
+
if re.fullmatch(
|
|
432
|
+
r"View (?:\d+|more) repl(?:y|ies)", control.inner_text().strip()
|
|
433
|
+
):
|
|
434
|
+
control.click(timeout=2000)
|
|
435
|
+
human_delay(page, options.delay, options.delay * 0.3)
|
|
436
|
+
container = _find_post_container(page, post_id) or container
|
|
437
|
+
break
|
|
438
|
+
return _collect_comments(container, options, post_id)
|
|
439
|
+
except Exception as error:
|
|
440
|
+
if options.verbose:
|
|
441
|
+
console.print(
|
|
442
|
+
f"[yellow]Warning: cannot read feed comments: {error}[/yellow]"
|
|
443
|
+
)
|
|
444
|
+
return []
|
|
445
|
+
finally:
|
|
446
|
+
if page.query_selector('[role="dialog"]'):
|
|
447
|
+
page.keyboard.press("Escape")
|
|
447
448
|
|
|
448
449
|
|
|
449
450
|
def scrape_comments_from_post_page(
|
|
@@ -462,7 +463,6 @@ def scrape_comments_from_post_page(
|
|
|
462
463
|
return []
|
|
463
464
|
|
|
464
465
|
comments: list[Comment] = []
|
|
465
|
-
seen_comment_ids: set[str] = set()
|
|
466
466
|
comment_page = page.context.new_page()
|
|
467
467
|
|
|
468
468
|
try:
|
|
@@ -499,51 +499,12 @@ def scrape_comments_from_post_page(
|
|
|
499
499
|
except Exception:
|
|
500
500
|
break
|
|
501
501
|
|
|
502
|
-
#
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
else:
|
|
509
|
-
# Without aria-labels every article matches, including the post
|
|
510
|
-
# itself: skip it (depth 0) and treat its direct article children
|
|
511
|
-
# as top-level comments (depth 1).
|
|
512
|
-
depth_selector = '[role="article"]'
|
|
513
|
-
top_level_depth = 1
|
|
514
|
-
|
|
515
|
-
for elem in comment_elements:
|
|
516
|
-
if _comment_article_depth(elem, depth_selector) != top_level_depth:
|
|
517
|
-
continue
|
|
518
|
-
|
|
519
|
-
comment = parse_modern_comment(
|
|
520
|
-
elem,
|
|
521
|
-
skip_reactions=options.skip_reactions,
|
|
522
|
-
verbose=options.verbose,
|
|
523
|
-
post_id=post_id or post_url,
|
|
524
|
-
)
|
|
525
|
-
if comment and comment.content and comment.id not in seen_comment_ids:
|
|
526
|
-
reply_ids: set[str] = set()
|
|
527
|
-
for reply_elem in _find_comment_articles(elem):
|
|
528
|
-
reply = parse_modern_comment(
|
|
529
|
-
reply_elem,
|
|
530
|
-
skip_reactions=options.skip_reactions,
|
|
531
|
-
verbose=options.verbose,
|
|
532
|
-
post_id=post_id or post_url,
|
|
533
|
-
)
|
|
534
|
-
if (
|
|
535
|
-
reply
|
|
536
|
-
and reply.content
|
|
537
|
-
and reply.id != comment.id
|
|
538
|
-
and reply.id not in seen_comment_ids
|
|
539
|
-
and reply.id not in reply_ids
|
|
540
|
-
):
|
|
541
|
-
comment.replies.append(reply)
|
|
542
|
-
reply_ids.add(reply.id)
|
|
543
|
-
seen_comment_ids.add(reply.id)
|
|
544
|
-
|
|
545
|
-
seen_comment_ids.add(comment.id)
|
|
546
|
-
comments.append(comment)
|
|
502
|
+
# A permalink may show a feed with unrelated posts beside the target.
|
|
503
|
+
target_id = post_id or extract_post_id(post_url)
|
|
504
|
+
if target_id:
|
|
505
|
+
container = _find_post_container(comment_page, target_id)
|
|
506
|
+
if container:
|
|
507
|
+
comments = _collect_comments(container, options, target_id)
|
|
547
508
|
|
|
548
509
|
except Exception as e:
|
|
549
510
|
if options.verbose:
|
|
@@ -557,13 +518,6 @@ def scrape_comments_from_post_page(
|
|
|
557
518
|
except Exception:
|
|
558
519
|
pass
|
|
559
520
|
|
|
560
|
-
# Apply filters
|
|
561
|
-
comments = filter_comments(
|
|
562
|
-
comments,
|
|
563
|
-
min_reactions=options.min_reactions,
|
|
564
|
-
top_n=options.top_comments,
|
|
565
|
-
)
|
|
566
|
-
|
|
567
521
|
return comments
|
|
568
522
|
|
|
569
523
|
|
|
@@ -703,7 +657,7 @@ def scrape_group(group: str, options: ScrapeOptions) -> ScrapeResult:
|
|
|
703
657
|
f"{len(articles)} are posts"
|
|
704
658
|
)
|
|
705
659
|
|
|
706
|
-
|
|
660
|
+
new_candidates_this_page = 0
|
|
707
661
|
|
|
708
662
|
for i, article in enumerate(articles):
|
|
709
663
|
identity = extract_post_identity(article)
|
|
@@ -735,6 +689,7 @@ def scrape_group(group: str, options: ScrapeOptions) -> ScrapeResult:
|
|
|
735
689
|
|
|
736
690
|
seen_post_ids.add(post.id)
|
|
737
691
|
diagnostics.candidates_seen += 1
|
|
692
|
+
new_candidates_this_page += 1
|
|
738
693
|
|
|
739
694
|
if post.timestamp:
|
|
740
695
|
if post.timestamp < since_date:
|
|
@@ -766,31 +721,25 @@ def scrape_group(group: str, options: ScrapeOptions) -> ScrapeResult:
|
|
|
766
721
|
)
|
|
767
722
|
|
|
768
723
|
# If no comments found and we have a post URL, try navigating to it
|
|
769
|
-
if not post.comments:
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
'a[href*="/posts/"], a[href*="?story_fbid"]'
|
|
724
|
+
if not post.comments and post.url:
|
|
725
|
+
post.comments = scrape_comments_from_post_page(
|
|
726
|
+
page, post.url, options, post_id=post.id
|
|
773
727
|
)
|
|
774
|
-
if permalink:
|
|
775
|
-
href = permalink.get_attribute("href")
|
|
776
|
-
if href:
|
|
777
|
-
if not href.startswith("http"):
|
|
778
|
-
href = f"https://www.facebook.com{href}"
|
|
779
|
-
post.comments = scrape_comments_from_post_page(
|
|
780
|
-
page, href, options, post_id=post.id
|
|
781
|
-
)
|
|
782
728
|
|
|
783
729
|
human_delay(page, options.delay * 0.5, options.delay * 0.2)
|
|
784
730
|
|
|
785
731
|
posts.append(post)
|
|
786
|
-
new_posts_this_page += 1
|
|
787
732
|
|
|
788
733
|
progress.update(task, description=f"Scraped {len(posts)} posts...")
|
|
789
734
|
|
|
790
735
|
if options.limit and len(posts) >= options.limit:
|
|
791
736
|
break
|
|
792
737
|
|
|
793
|
-
if
|
|
738
|
+
if options.limit and len(posts) >= options.limit:
|
|
739
|
+
diagnostics.stop_reason = "limit"
|
|
740
|
+
break
|
|
741
|
+
|
|
742
|
+
if new_candidates_this_page == 0:
|
|
794
743
|
pages_without_new_posts += 1
|
|
795
744
|
else:
|
|
796
745
|
pages_without_new_posts = 0
|
|
@@ -862,7 +811,9 @@ def search_marketplace(
|
|
|
862
811
|
options: MarketplaceOptions,
|
|
863
812
|
) -> MarketplaceResult:
|
|
864
813
|
"""Search Facebook Marketplace for electronics listings."""
|
|
865
|
-
search_url = get_marketplace_url(
|
|
814
|
+
search_url = get_marketplace_url(
|
|
815
|
+
query, options.city, options.radius, options.sort_by
|
|
816
|
+
)
|
|
866
817
|
listings: list[MarketplaceListing] = []
|
|
867
818
|
seen_ids: set[str] = set()
|
|
868
819
|
diagnostics = CollectionDiagnostics(stop_reason="no_new_listings")
|
|
@@ -912,27 +863,31 @@ def search_marketplace(
|
|
|
912
863
|
):
|
|
913
864
|
previous_count = len(seen_ids)
|
|
914
865
|
for element in page.query_selector_all('a[href*="/marketplace/item/"]'):
|
|
866
|
+
href = element.get_attribute("href") or ""
|
|
867
|
+
identity = re.search(r"/marketplace/item/(\d+)", href)
|
|
868
|
+
if identity is None or identity.group(1) in seen_ids:
|
|
869
|
+
continue
|
|
915
870
|
listing = parse_marketplace_listing(element, verbose=options.verbose)
|
|
916
871
|
if listing is None:
|
|
917
872
|
diagnostics.parse_failures += 1
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
873
|
+
continue
|
|
874
|
+
seen_ids.add(listing.id)
|
|
875
|
+
diagnostics.candidates_seen += 1
|
|
876
|
+
if _listing_matches_radius(
|
|
877
|
+
detail_page,
|
|
878
|
+
listing,
|
|
879
|
+
center,
|
|
880
|
+
options.radius,
|
|
881
|
+
options.verbose,
|
|
882
|
+
):
|
|
883
|
+
listings.append(listing)
|
|
884
|
+
else:
|
|
885
|
+
diagnostics.rejected_count += 1
|
|
886
|
+
if (
|
|
887
|
+
len(listings) >= options.limit
|
|
888
|
+
or len(seen_ids) >= options.max_candidates
|
|
889
|
+
):
|
|
890
|
+
break
|
|
936
891
|
|
|
937
892
|
empty_scrolls = empty_scrolls + 1 if len(seen_ids) == previous_count else 0
|
|
938
893
|
if len(listings) < options.limit and len(seen_ids) < options.max_candidates:
|
foragefacebook-2.2.0/Makefile
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
.PHONY: check test 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
|
-
build:
|
|
14
|
-
uv build --no-config
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Check real Facebook reads through the installed CLI and saved session."""
|
|
3
|
-
|
|
4
|
-
import json
|
|
5
|
-
import os
|
|
6
|
-
import shlex
|
|
7
|
-
import subprocess
|
|
8
|
-
import sys
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def main():
|
|
12
|
-
cli = shlex.split(os.environ.get("FORAGE_BIN", "forage"))
|
|
13
|
-
|
|
14
|
-
def call(*args):
|
|
15
|
-
result = subprocess.run(
|
|
16
|
-
[*cli, "--no-color", *args], capture_output=True, text=True, timeout=120
|
|
17
|
-
)
|
|
18
|
-
if result.returncode:
|
|
19
|
-
raise RuntimeError(
|
|
20
|
-
f"{args[0]} failed with exit {result.returncode}; run the command to inspect the error"
|
|
21
|
-
)
|
|
22
|
-
return json.loads(result.stdout)
|
|
23
|
-
|
|
24
|
-
doctor = call("doctor")
|
|
25
|
-
if not doctor.get("browser_installed") or not doctor.get("session_exists"):
|
|
26
|
-
raise RuntimeError(
|
|
27
|
-
"Install Chromium and save a Facebook session before live tests"
|
|
28
|
-
)
|
|
29
|
-
group = os.environ.get("FORAGE_E2E_GROUP")
|
|
30
|
-
if group:
|
|
31
|
-
result = call("scrape", group, "--limit", "1", "--skip-comments", "--no-input")
|
|
32
|
-
rows = result.get("posts")
|
|
33
|
-
else:
|
|
34
|
-
result = call(
|
|
35
|
-
"marketplace", "computer", "--limit", "1", "--max-candidates", "3"
|
|
36
|
-
)
|
|
37
|
-
rows = result.get("listings")
|
|
38
|
-
if not isinstance(rows, list) or not rows or not rows[0].get("id"):
|
|
39
|
-
raise RuntimeError(
|
|
40
|
-
"Live read returned no verified record; inspect access and page layout"
|
|
41
|
-
)
|
|
42
|
-
print("PASS real Facebook CLI read")
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if __name__ == "__main__":
|
|
46
|
-
try:
|
|
47
|
-
main()
|
|
48
|
-
except (
|
|
49
|
-
RuntimeError,
|
|
50
|
-
ValueError,
|
|
51
|
-
KeyError,
|
|
52
|
-
TypeError,
|
|
53
|
-
OSError,
|
|
54
|
-
subprocess.TimeoutExpired,
|
|
55
|
-
) as error:
|
|
56
|
-
print(f"FAIL {error}", file=sys.stderr)
|
|
57
|
-
sys.exit(1)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{foragefacebook-2.2.0 → foragefacebook-2.3.0}/docs/research/2026-09-20-cli-portfolio-audit.md
RENAMED
|
File without changes
|
{foragefacebook-2.2.0 → foragefacebook-2.3.0}/docs/research/2026-09-20-cli-portfolio-repairs.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|