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.
- agentic_facebook/__init__.py +181 -0
- agentic_facebook/catalog.py +156 -0
- agentic_facebook/chrome.py +179 -0
- agentic_facebook/cli.py +781 -0
- agentic_facebook/comments.py +250 -0
- agentic_facebook/config.py +103 -0
- agentic_facebook/errors.py +59 -0
- agentic_facebook/exits.py +48 -0
- agentic_facebook/graphql.py +244 -0
- agentic_facebook/model.py +425 -0
- agentic_facebook/parse.py +333 -0
- agentic_facebook/profiles.py +170 -0
- agentic_facebook/queries.py +305 -0
- agentic_facebook/redact.py +122 -0
- agentic_facebook/retrieve.py +831 -0
- agentic_facebook/scroll.py +244 -0
- agentic_facebook/search.py +155 -0
- agentic_facebook/session.py +227 -0
- agentic_facebook/tokens.py +232 -0
- agentic_facebook/truncation.py +70 -0
- agentic_facebook-0.4.0.dist-info/METADATA +144 -0
- agentic_facebook-0.4.0.dist-info/RECORD +25 -0
- agentic_facebook-0.4.0.dist-info/WHEEL +4 -0
- agentic_facebook-0.4.0.dist-info/entry_points.txt +2 -0
- agentic_facebook-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|