agentic-facebook 0.4.0__py3-none-any.whl

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.
@@ -0,0 +1,181 @@
1
+ """Public Python API (plan §5). See README.md for the full picture."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.4.0"
6
+
7
+ from collections.abc import Iterator
8
+ from datetime import date
9
+ from pathlib import Path
10
+
11
+ from . import profiles
12
+ from . import retrieve as retrieve_module
13
+ from . import session as session_module
14
+ from .config import DEFAULT_MAX_SCROLLS, DEFAULT_PROFILE_NAME, DEFAULT_SCROLL_PAUSE
15
+ from .errors import (
16
+ AgenticFacebookError,
17
+ ChallengeError,
18
+ InvalidIdentifierError,
19
+ LoginRequiredError,
20
+ ProfileUnavailableError,
21
+ SessionClosedError,
22
+ SessionExpiredError,
23
+ )
24
+ from .model import LinkAttachment, Media, Post
25
+ from .retrieve import RetrieveResult
26
+ from .session import Status
27
+
28
+ __all__ = [
29
+ "FacebookScraper",
30
+ "Post",
31
+ "Media",
32
+ "LinkAttachment",
33
+ "Status",
34
+ "RetrieveResult",
35
+ "AgenticFacebookError",
36
+ "LoginRequiredError",
37
+ "SessionExpiredError",
38
+ "ChallengeError",
39
+ "ProfileUnavailableError",
40
+ "SessionClosedError",
41
+ "InvalidIdentifierError",
42
+ ]
43
+
44
+
45
+ def _parse_date(value: str | date | None) -> date | None:
46
+ if value is None or isinstance(value, date):
47
+ return value
48
+ return date.fromisoformat(value) # strict YYYY-MM-DD; raises ValueError otherwise
49
+
50
+
51
+ class _HybridLogin:
52
+ """Makes ``login`` behave differently on the class vs. an instance:
53
+
54
+ - ``FacebookScraper.login(profile=..., profile_dir=...)`` (class access)
55
+ CONSTRUCTS a new instance with those keywords, then logs it in — so it
56
+ needs them.
57
+ - ``FacebookScraper(profile=...).login()`` (instance access) takes NO
58
+ keywords — ``profile``/``profile_dir`` are already fixed by the
59
+ instance you're calling it on; passing them again would be ambiguous
60
+ (log into the instance's own profile, or silently reconstruct a
61
+ different one?), so it's a plain ``TypeError`` instead of guessing.
62
+
63
+ Plain ``def``/``@classmethod`` can't express this one name having two
64
+ different call shapes — the second definition would just shadow the
65
+ first in the class namespace. This closes the gap the plan calls out
66
+ explicitly (§5): a classmethod-only shim can't forward a caller's custom
67
+ ``profile_dir`` into the same session it then logs into, so a library
68
+ user with a non-default ``profile_dir`` would log into one place and
69
+ fetch from another.
70
+ """
71
+
72
+ def __get__(self, obj, objtype=None):
73
+ if obj is not None:
74
+ return obj._login_instance
75
+
76
+ def classmethod_shim(
77
+ profile: str = DEFAULT_PROFILE_NAME,
78
+ *,
79
+ profile_dir: str | Path | None = None,
80
+ ) -> bool:
81
+ return objtype(profile=profile, profile_dir=profile_dir)._login_instance()
82
+
83
+ return classmethod_shim
84
+
85
+
86
+ class FacebookScraper:
87
+ """Scrape your own logged-in Facebook timeline. See DISCLAIMER.md first."""
88
+
89
+ def __init__(
90
+ self,
91
+ profile: str = DEFAULT_PROFILE_NAME,
92
+ *,
93
+ headless: bool = True,
94
+ profile_dir: str | Path | None = None,
95
+ scroll_pause: tuple[float, float] = DEFAULT_SCROLL_PAUSE,
96
+ max_scrolls: int = DEFAULT_MAX_SCROLLS,
97
+ ) -> None:
98
+ self.profile = profile
99
+ self.headless = headless
100
+ self.scroll_pause = scroll_pause
101
+ self.max_scrolls = max_scrolls
102
+ self.last_result: RetrieveResult | None = None
103
+ self._profile_dir = profiles.resolve_profile_dir(profile, profile_dir)
104
+ self._closed = False
105
+
106
+ def __enter__(self) -> FacebookScraper:
107
+ return self
108
+
109
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
110
+ self._closed = True
111
+
112
+ def _login_instance(self) -> bool:
113
+ return session_module.run_login(self._profile_dir)
114
+
115
+ login = _HybridLogin()
116
+
117
+ def status(self) -> Status:
118
+ return session_module.run_status(self._profile_dir)
119
+
120
+ def fetch_profile(
121
+ self,
122
+ url: str,
123
+ *,
124
+ limit: int | None = None,
125
+ since: str | date | None = None,
126
+ until: str | date | None = None,
127
+ raw: bool = False,
128
+ ) -> list[Post]:
129
+ if self._closed:
130
+ raise SessionClosedError("FacebookScraper is closed; use it inside a `with` block")
131
+ normalized_url = profiles.normalize_target_identifier(url)
132
+ result = retrieve_module.fetch_profile(
133
+ normalized_url,
134
+ profile_dir=self._profile_dir,
135
+ # Must travel with profile_dir, not default separately: active mode
136
+ # keys its token cache by profile NAME while the browser session is
137
+ # keyed by DIRECTORY. Letting the name fall back to "default" while
138
+ # the directory points at another profile makes the two disagree —
139
+ # the run would read (and overwrite) a different account's cached
140
+ # cookies than the browser profile it is actually driving.
141
+ profile_name=self.profile,
142
+ headless=self.headless,
143
+ limit=limit,
144
+ since=_parse_date(since),
145
+ until=_parse_date(until),
146
+ scroll_pause=self.scroll_pause,
147
+ max_scrolls=self.max_scrolls,
148
+ raw=raw,
149
+ )
150
+ self.last_result = result
151
+ return result.posts
152
+
153
+ def iter_profile(
154
+ self,
155
+ url: str,
156
+ *,
157
+ limit: int | None = None,
158
+ since: str | date | None = None,
159
+ until: str | date | None = None,
160
+ raw: bool = False,
161
+ ) -> Iterator[Post]:
162
+ """Generator form. Must be consumed inside the owning ``with`` block —
163
+ advancing it (the first ``next()``, e.g. via a ``for`` loop) after the
164
+ block exited raises :class:`SessionClosedError` rather than touching
165
+ an already-closed session. Because this is a generator, that check
166
+ cannot run at call time — calling ``iter_profile()`` itself never
167
+ raises, even on an already-closed instance; only advancing it does.
168
+
169
+ Note this fully scrolls, captures, and parses before yielding the
170
+ first post — like ``fetch_profile``, just yielded one at a time
171
+ afterward. Breaking out of the loop early doesn't reduce browser
172
+ work already done.
173
+ """
174
+ if self._closed:
175
+ raise SessionClosedError("iter_profile() was advanced after its `with` block exited")
176
+ for post in self.fetch_profile(url, limit=limit, since=since, until=until, raw=raw):
177
+ if self._closed:
178
+ raise SessionClosedError(
179
+ "iter_profile() was advanced after its `with` block exited"
180
+ )
181
+ yield post
@@ -0,0 +1,156 @@
1
+ """``agentic-facebook catalog`` — the CLI describing itself, so nothing has to re-type it.
2
+
3
+ The problem this solves: anything that wants to *explain* this tool (the README,
4
+ a `.claude` skill, an agent's prompt) previously had to transcribe the command
5
+ list, the flags, their choices, and the exit codes. A transcription drifts the
6
+ moment a flag is added, and a stale description is worse than none — a model
7
+ trusts it over its own reading of `--help`.
8
+
9
+ So the catalog is **derived, never authored**: the command and option tables are
10
+ introspected from the live ``argparse`` parser, the object schemas come from the
11
+ same ``to_dict()``-anchored functions ``schema`` uses, and the exit codes come
12
+ from ``exits.DESCRIPTIONS``. Adding a command or a flag updates this output with
13
+ no edit here, and ``tests/test_catalog.py`` fails if that ever stops being true.
14
+
15
+ What is genuinely authored here is the short list below of behaviors that no
16
+ amount of introspection can reach — the output contract and the known
17
+ limitations. They live in code, next to the thing they describe, so there is
18
+ still exactly one copy.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ from typing import Any
25
+
26
+ from . import exits
27
+ from .comments import schema_fields as comment_schema_fields
28
+ from .model import schema_fields as post_schema_fields
29
+ from .search import schema_fields as entity_schema_fields
30
+
31
+ #: Facts about how every retrieval command behaves that argparse cannot express.
32
+ #: The first entry is the single most common mistake made against this CLI.
33
+ OUTPUT_CONTRACT = [
34
+ "Results are written to a JSON FILE. Only a one-line summary goes to stderr, "
35
+ "and nothing useful goes to stdout — run the command, then read the file.",
36
+ "Pass --output PATH to choose that file. Without it, results land under the "
37
+ "platform data directory with a timestamped name (never the current directory, "
38
+ "because captured posts contain third-party personal data).",
39
+ "--format json emits one array; --format ndjson emits one object per line.",
40
+ "fetch/feed/post/search/group emit Post objects; comments emits Comment objects; "
41
+ "search --type people|pages|groups emits Entity objects instead of Posts.",
42
+ ]
43
+
44
+ #: Known limitations that look like bugs. Authored (they are not derivable), but
45
+ #: authored HERE so the skill and the README can point at them instead of copying.
46
+ LIMITATIONS = [
47
+ "Active mode replays Facebook query ids (doc_id) that rotate when Facebook ships "
48
+ "a client build. `fetch` falls back to the browser automatically; feed/comments/"
49
+ "post/search/group are active-only and simply fail until the package is updated.",
50
+ "Passive mode cannot see a profile's newest post — the first timeline batch is "
51
+ "server-rendered into the HTML, never fetched as a GraphQL request. Active mode can.",
52
+ "post/comments require a real post permalink. Reel URLs are unsupported (a reel "
53
+ "page embeds no story id).",
54
+ "comments --replies fetches depth-1 replies only; a comment's reply_count includes "
55
+ "deeper nested replies that are not returned.",
56
+ "--limit on comments counts top-level comments only, so one heavily-replied comment "
57
+ "cannot consume the whole budget.",
58
+ "Requests are rate-floored in code and cannot be bypassed: >= 1.0s between active "
59
+ "requests, >= 0.5s between scrolls.",
60
+ ]
61
+
62
+
63
+ def _subparser_action(parser: argparse.ArgumentParser) -> argparse._SubParsersAction | None:
64
+ for action in parser._actions:
65
+ if isinstance(action, argparse._SubParsersAction):
66
+ return action
67
+ return None
68
+
69
+
70
+ def _describe_option(action: argparse.Action) -> dict[str, Any] | None:
71
+ """One flag, as the catalog reports it. ``None`` for things not worth listing."""
72
+ if isinstance(action, argparse._HelpAction | argparse._VersionAction):
73
+ return None
74
+ entry: dict[str, Any] = {
75
+ "flags": list(action.option_strings) or [action.dest],
76
+ "help": (action.help or "").replace("%(default)s", str(action.default)),
77
+ "required": bool(action.required),
78
+ }
79
+ if action.choices:
80
+ entry["choices"] = sorted(str(choice) for choice in action.choices)
81
+ if action.default is not None and not isinstance(action, argparse._StoreTrueAction):
82
+ entry["default"] = action.default
83
+ if not action.option_strings:
84
+ entry["positional"] = True
85
+ return entry
86
+
87
+
88
+ def build_catalog(parser: argparse.ArgumentParser) -> dict[str, Any]:
89
+ """The whole self-description, derived from ``parser`` plus the schema functions."""
90
+ from . import __version__
91
+
92
+ subparsers = _subparser_action(parser)
93
+ commands: dict[str, Any] = {}
94
+ if subparsers is not None:
95
+ # choices maps name -> subparser; _choices_actions carries the help strings.
96
+ help_by_name = {choice.dest: (choice.help or "") for choice in subparsers._choices_actions}
97
+ for name, subparser in subparsers.choices.items():
98
+ options = [_describe_option(a) for a in subparser._actions]
99
+ commands[name] = {
100
+ "summary": help_by_name.get(name, ""),
101
+ "options": [option for option in options if option is not None],
102
+ }
103
+
104
+ return {
105
+ "tool": parser.prog,
106
+ "version": __version__,
107
+ "commands": commands,
108
+ "exit_codes": {str(code): text for code, text in sorted(exits.DESCRIPTIONS.items())},
109
+ "output_contract": OUTPUT_CONTRACT,
110
+ "limitations": LIMITATIONS,
111
+ "object_types": {
112
+ "Post": post_schema_fields(),
113
+ "Comment": comment_schema_fields(),
114
+ "Entity": entity_schema_fields(),
115
+ },
116
+ }
117
+
118
+
119
+ def _format_option(option: dict[str, Any]) -> str:
120
+ flags = ", ".join(option["flags"])
121
+ if option.get("choices"):
122
+ flags += " {" + "|".join(option["choices"]) + "}"
123
+ return f" {flags}\n {option['help']}"
124
+
125
+
126
+ def render_text(catalog: dict[str, Any]) -> str:
127
+ """The human/agent-readable rendering. Same content, no JSON parsing needed."""
128
+ lines = [f"{catalog['tool']} {catalog['version']} — command catalog", ""]
129
+
130
+ lines.append("OUTPUT CONTRACT (read this first)")
131
+ for item in catalog["output_contract"]:
132
+ lines.append(f" - {item}")
133
+ lines.append("")
134
+
135
+ lines.append("COMMANDS")
136
+ for name, command in catalog["commands"].items():
137
+ lines.append(f" {name} — {command['summary']}")
138
+ for option in command["options"]:
139
+ lines.append(_format_option(option))
140
+ lines.append("")
141
+
142
+ lines.append("EXIT CODES")
143
+ for code, text in catalog["exit_codes"].items():
144
+ lines.append(f" {code}: {text}")
145
+ lines.append("")
146
+
147
+ lines.append("OBJECT TYPES (full field descriptions: agentic-facebook schema)")
148
+ for type_name, fields in catalog["object_types"].items():
149
+ names = ", ".join(field["name"] for field in fields)
150
+ lines.append(f" {type_name}: {names}")
151
+ lines.append("")
152
+
153
+ lines.append("KNOWN LIMITATIONS")
154
+ for item in catalog["limitations"]:
155
+ lines.append(f" - {item}")
156
+ return "\n".join(lines)
@@ -0,0 +1,179 @@
1
+ """Opt-in: reuse the Facebook session in your everyday Chrome (plan §3a, option a).
2
+
3
+ **This is literal cookie extraction**, which is exactly what the default path
4
+ avoids — hence opt-in, never automatic. It reads Chrome's encryption key from
5
+ the macOS Keychain (which may prompt once) and decrypts the cookie values out
6
+ of Chrome's own database.
7
+
8
+ Why decryption is unavoidable: simply copying a logged-in Chrome profile and
9
+ opening it with Playwright **fails**. Playwright launches Chrome with
10
+ ``--use-mock-keychain``, so Chrome cannot reach the real "Chrome Safe Storage"
11
+ Keychain entry, every cookie fails to decrypt, and the copy opens logged out
12
+ (recon §5.4).
13
+
14
+ Importing an everyday browser usually means importing your *main* account —
15
+ against this project's throwaway-account guidance. Prefer ``agentic-facebook login``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import os
22
+ import shutil
23
+ import sqlite3
24
+ import subprocess
25
+ import tempfile
26
+ from pathlib import Path
27
+
28
+ from .errors import AgenticFacebookError
29
+
30
+ #: Constants of Chrome's macOS cookie encryption (stable for many years).
31
+ _SALT = b"saltysalt"
32
+ _ITERATIONS = 1003
33
+ _KEY_LENGTH = 16
34
+ _IV = b" " * 16
35
+
36
+
37
+ class ChromeImportError(AgenticFacebookError):
38
+ """Chrome's cookies could not be read or decrypted."""
39
+
40
+
41
+ def _chrome_user_data_dir() -> Path:
42
+ return Path.home() / "Library" / "Application Support" / "Google" / "Chrome"
43
+
44
+
45
+ def list_profiles_with_facebook_session() -> list[str]:
46
+ """Chrome profile names that hold a ``facebook.com`` ``c_user`` cookie.
47
+
48
+ Reads cookie *names and domains only* — never a value — so identifying the
49
+ right profile costs no decryption and triggers no Keychain prompt.
50
+ """
51
+ found: list[str] = []
52
+ root = _chrome_user_data_dir()
53
+ if not root.exists():
54
+ return found
55
+ for cookie_db in sorted(root.glob("*/Cookies")):
56
+ try:
57
+ with _temp_copy(cookie_db) as path:
58
+ connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
59
+ try:
60
+ row = connection.execute(
61
+ "SELECT 1 FROM cookies WHERE host_key LIKE '%facebook.com' "
62
+ "AND name = 'c_user' LIMIT 1"
63
+ ).fetchone()
64
+ finally:
65
+ connection.close()
66
+ if row:
67
+ found.append(cookie_db.parent.name)
68
+ except (OSError, sqlite3.Error):
69
+ continue
70
+ return found
71
+
72
+
73
+ class _temp_copy:
74
+ """Chrome holds a lock on the live DB; work on a copy."""
75
+
76
+ def __init__(self, source: Path) -> None:
77
+ self._source = source
78
+ self._dir: str | None = None
79
+
80
+ def __enter__(self) -> Path:
81
+ self._dir = tempfile.mkdtemp(prefix="sfb-chrome-")
82
+ target = Path(self._dir) / "Cookies"
83
+ shutil.copy2(self._source, target)
84
+ os.chmod(target, 0o600)
85
+ return target
86
+
87
+ def __exit__(self, *exc) -> None:
88
+ if self._dir:
89
+ shutil.rmtree(self._dir, ignore_errors=True)
90
+
91
+
92
+ def _safe_storage_password() -> str:
93
+ try:
94
+ result = subprocess.run(
95
+ ["security", "find-generic-password", "-w", "-s", "Chrome Safe Storage"],
96
+ capture_output=True,
97
+ text=True,
98
+ check=True,
99
+ )
100
+ except (OSError, subprocess.CalledProcessError) as exc:
101
+ raise ChromeImportError(
102
+ "could not read the 'Chrome Safe Storage' key from the Keychain "
103
+ "(it may need to be allowed once, interactively)"
104
+ ) from exc
105
+ return result.stdout.strip()
106
+
107
+
108
+ def _derive_key(password: str) -> bytes:
109
+ return hashlib.pbkdf2_hmac("sha1", password.encode(), _SALT, _ITERATIONS, _KEY_LENGTH)
110
+
111
+
112
+ def _decrypt(value: bytes, key: bytes) -> str:
113
+ try:
114
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
115
+ except ImportError as exc: # pragma: no cover - depends on an optional extra
116
+ raise ChromeImportError(
117
+ "--from-chrome needs the optional 'chrome' extra: "
118
+ "pip install 'agentic-facebook[chrome]'"
119
+ ) from exc
120
+
121
+ if not value.startswith(b"v10"):
122
+ # An unencrypted value (rare, older Chrome) is already plaintext.
123
+ return value.decode("utf-8", errors="replace")
124
+
125
+ decryptor = Cipher(algorithms.AES(key), modes.CBC(_IV)).decryptor()
126
+ plaintext = decryptor.update(value[3:]) + decryptor.finalize()
127
+ if plaintext:
128
+ padding = plaintext[-1]
129
+ if 1 <= padding <= 16:
130
+ plaintext = plaintext[:-padding]
131
+ # Chrome >= 130 prefixes the plaintext with a 32-byte SHA256 of the domain.
132
+ # Detected structurally (non-UTF8 leading bytes) rather than by version
133
+ # sniffing, so both layouts work without knowing which Chrome wrote it.
134
+ try:
135
+ return plaintext.decode("utf-8")
136
+ except UnicodeDecodeError:
137
+ return plaintext[32:].decode("utf-8", errors="replace")
138
+
139
+
140
+ def load_facebook_cookies(profile: str = "Default") -> dict[str, str]:
141
+ """Decrypted ``facebook.com`` cookies from a local Chrome profile.
142
+
143
+ Raises :class:`ChromeImportError` if the profile, the Keychain key, or the
144
+ session itself is missing — never returns a half-usable cookie jar.
145
+ """
146
+ cookie_db = _chrome_user_data_dir() / profile / "Cookies"
147
+ if not cookie_db.exists():
148
+ available = ", ".join(list_profiles_with_facebook_session()) or "none found"
149
+ raise ChromeImportError(
150
+ f"no Chrome cookie database for profile {profile!r} "
151
+ f"(profiles with a Facebook session: {available})"
152
+ )
153
+
154
+ key = _derive_key(_safe_storage_password())
155
+ cookies: dict[str, str] = {}
156
+ with _temp_copy(cookie_db) as path:
157
+ connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
158
+ try:
159
+ rows = connection.execute(
160
+ "SELECT name, value, encrypted_value FROM cookies "
161
+ "WHERE host_key LIKE '%facebook.com'"
162
+ ).fetchall()
163
+ finally:
164
+ connection.close()
165
+
166
+ for name, value, encrypted in rows:
167
+ if value:
168
+ cookies[name] = value
169
+ elif encrypted:
170
+ decrypted = _decrypt(encrypted, key)
171
+ if decrypted:
172
+ cookies[name] = decrypted
173
+
174
+ if "c_user" not in cookies or "xs" not in cookies:
175
+ raise ChromeImportError(
176
+ f"Chrome profile {profile!r} has no logged-in Facebook session "
177
+ "(no c_user/xs cookie after decryption)"
178
+ )
179
+ return cookies