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.
Files changed (31) hide show
  1. foragefacebook-2.3.0/.python-version +1 -0
  2. {foragefacebook-2.2.0 → foragefacebook-2.3.0}/AGENTS.md +3 -0
  3. {foragefacebook-2.2.0 → foragefacebook-2.3.0}/CHANGELOG.md +23 -1
  4. {foragefacebook-2.2.0 → foragefacebook-2.3.0}/CONTRIBUTING.md +11 -3
  5. foragefacebook-2.3.0/Makefile +25 -0
  6. {foragefacebook-2.2.0 → foragefacebook-2.3.0}/PKG-INFO +21 -4
  7. {foragefacebook-2.2.0 → foragefacebook-2.3.0}/README.md +20 -3
  8. {foragefacebook-2.2.0 → foragefacebook-2.3.0}/docs/capabilities.md +19 -8
  9. {foragefacebook-2.2.0 → foragefacebook-2.3.0}/pyproject.toml +1 -1
  10. foragefacebook-2.3.0/scripts/live-e2e.py +164 -0
  11. {foragefacebook-2.2.0 → foragefacebook-2.3.0}/src/forage/__init__.py +1 -1
  12. {foragefacebook-2.2.0 → foragefacebook-2.3.0}/src/forage/cli.py +19 -1
  13. {foragefacebook-2.2.0 → foragefacebook-2.3.0}/src/forage/parser.py +47 -26
  14. {foragefacebook-2.2.0 → foragefacebook-2.3.0}/src/forage/scraper.py +144 -189
  15. {foragefacebook-2.2.0 → foragefacebook-2.3.0}/uv.lock +1 -1
  16. foragefacebook-2.2.0/Makefile +0 -14
  17. foragefacebook-2.2.0/scripts/live-e2e.py +0 -57
  18. {foragefacebook-2.2.0 → foragefacebook-2.3.0}/.github/FUNDING.yml +0 -0
  19. {foragefacebook-2.2.0 → foragefacebook-2.3.0}/.github/dependabot.yml +0 -0
  20. {foragefacebook-2.2.0 → foragefacebook-2.3.0}/.github/workflows/ci.yml +0 -0
  21. {foragefacebook-2.2.0 → foragefacebook-2.3.0}/.github/workflows/publish.yml +0 -0
  22. {foragefacebook-2.2.0 → foragefacebook-2.3.0}/.gitignore +0 -0
  23. {foragefacebook-2.2.0 → foragefacebook-2.3.0}/CLAUDE.md +0 -0
  24. {foragefacebook-2.2.0 → foragefacebook-2.3.0}/LICENSE +0 -0
  25. {foragefacebook-2.2.0 → foragefacebook-2.3.0}/SECURITY.md +0 -0
  26. {foragefacebook-2.2.0 → foragefacebook-2.3.0}/docs/research/2026-09-20-cli-portfolio-audit.md +0 -0
  27. {foragefacebook-2.2.0 → foragefacebook-2.3.0}/docs/research/2026-09-20-cli-portfolio-repairs.md +0 -0
  28. {foragefacebook-2.2.0 → foragefacebook-2.3.0}/scripts/release.sh +0 -0
  29. {foragefacebook-2.2.0 → foragefacebook-2.3.0}/src/forage/auth.py +0 -0
  30. {foragefacebook-2.2.0 → foragefacebook-2.3.0}/src/forage/exporter.py +0 -0
  31. {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.2.0...HEAD
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. Ensure tests pass (`make check`; run `make test` with a saved session)
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
- - Mock external dependencies (Playwright, network)
70
- - Aim for good coverage of edge cases
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.2.0
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 current parser supports electronics with dollar prices or “Free” labels. Search relevance follows Facebook's results.
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 one post from a group you can access
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 sanitized DOM fixtures and verified selector updates. Add incremental collection only after identity and completeness checks support it.
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 current parser supports electronics with dollar prices or “Free” labels. Search relevance follows Facebook's results.
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 one post from a group you can access
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 sanitized DOM fixtures and verified selector updates. Add incremental collection only after identity and completeness checks support it.
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-09-20. This file describes the tested application behavior, not exhaustive Facebook coverage.
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 | Supported HTML fixtures, full visible paragraphs, nested-comment exclusion, supported expansion controls, permalinks | English labels; Facebook layout changes; media and attachment extraction absent |
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 | Native IDs, fallback IDs scoped to posts, supported reply collection | All-comment coverage remains unverified; identical fallback comments within one post can collide; comment timestamps remain unknown |
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 | Electronics, dollar/free prices, title/location, accepted result limit, 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 |
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 current audit cannot retrieve that page or current [Marketplace documentation](https://developers.facebook.com/docs/marketplace/) because Meta returns 429 responses.
34
- Current replacement availability and exhaustive REST coverage therefore remain unknown. Do not infer private APIs from DOM fields.
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
- All new selector checks use synthetic, network-blocked Chromium pages. No live authenticated Facebook session validates this change.
37
- Verify selector changes through read-only live CLI requests. Never publish session state or private group content.
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.2.0"
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"
@@ -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)
@@ -1,3 +1,3 @@
1
1
  """Forage: CLI tool to scrape private Facebook groups."""
2
2
 
3
- __version__ = "2.2.0"
3
+ __version__ = "2.3.0"
@@ -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, newest first."""
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*="?story_fbid"]'
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*\n?(\d+)", all_text)
514
+ match = re.search(r"All reactions:?\s*([\d,.]+\s*[kKmM]?)", all_text)
503
515
  if match:
504
- reactions = Reactions(total=int(match.group(1)))
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/" in href
585
- and "/groups/" not in href
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 = href
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 = None
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].startswith("$"):
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(query: str, city: str, radius: int) -> str:
170
- """Build a newest-first Marketplace electronics search URL."""
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
- "radius": radius,
176
- "sortBy": "creation_time_descend",
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
- until_date = now
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('[role="article"][aria-label^="Comment by"]'))
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 scrape_post_comments(
349
- page: Page,
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
- Scrape comments from a post by clicking to expand them.
357
-
358
- This works on the modern Facebook interface.
359
- """
360
- if options.skip_comments:
361
- return []
362
-
363
- comments: list[Comment] = []
364
- seen_comment_ids: set[str] = set()
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
- elem,
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 and comment.content and comment.id not in seen_comment_ids:
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
- seen_comment_ids.add(comment.id)
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
- # Apply filters
440
- comments = filter_comments(
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
- return comments
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
- # Find all comment elements
503
- comment_elements = _find_comment_articles(comment_page)
504
- use_comment_aria = _has_comment_aria(comment_page)
505
- if use_comment_aria:
506
- depth_selector = '[role="article"][aria-label^="Comment by"]'
507
- top_level_depth = 0
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
- new_posts_this_page = 0
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
- # Try to find permalink
771
- permalink = article.query_selector(
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 new_posts_this_page == 0:
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(query, options.city, options.radius)
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
- if listing and listing.id not in seen_ids:
919
- seen_ids.add(listing.id)
920
- diagnostics.candidates_seen += 1
921
- if _listing_matches_radius(
922
- detail_page,
923
- listing,
924
- center,
925
- options.radius,
926
- options.verbose,
927
- ):
928
- listings.append(listing)
929
- else:
930
- diagnostics.rejected_count += 1
931
- if (
932
- len(listings) >= options.limit
933
- or len(seen_ids) >= options.max_candidates
934
- ):
935
- break
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:
@@ -34,7 +34,7 @@ wheels = [
34
34
 
35
35
  [[package]]
36
36
  name = "foragefacebook"
37
- version = "2.2.0"
37
+ version = "2.3.0"
38
38
  source = { editable = "." }
39
39
  dependencies = [
40
40
  { name = "click" },
@@ -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