session-bundle-kit 0.3.1__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.
- session_bundle_kit/__init__.py +20 -0
- session_bundle_kit/bundle.py +123 -0
- session_bundle_kit/cli.py +189 -0
- session_bundle_kit/deal.py +25 -0
- session_bundle_kit/diff.py +120 -0
- session_bundle_kit/export.py +33 -0
- session_bundle_kit/import_bundle.py +110 -0
- session_bundle_kit/mlx.py +285 -0
- session_bundle_kit/models.py +113 -0
- session_bundle_kit/security.py +38 -0
- session_bundle_kit-0.3.1.dist-info/METADATA +248 -0
- session_bundle_kit-0.3.1.dist-info/RECORD +15 -0
- session_bundle_kit-0.3.1.dist-info/WHEEL +4 -0
- session_bundle_kit-0.3.1.dist-info/entry_points.txt +2 -0
- session_bundle_kit-0.3.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Session bundle export, import, and diff toolkit."""
|
|
2
|
+
|
|
3
|
+
from session_bundle_kit.bundle import SessionBundle
|
|
4
|
+
from session_bundle_kit.diff import diff_bundles
|
|
5
|
+
from session_bundle_kit.export import export_playwright_context
|
|
6
|
+
from session_bundle_kit.import_bundle import import_bundle, validate_bundle
|
|
7
|
+
from session_bundle_kit.models import BUNDLE_SPEC, BUNDLE_VERSION
|
|
8
|
+
from session_bundle_kit.security import scan_plaintext_secrets
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"BUNDLE_SPEC",
|
|
12
|
+
"BUNDLE_VERSION",
|
|
13
|
+
"SessionBundle",
|
|
14
|
+
"diff_bundles",
|
|
15
|
+
"export_playwright_context",
|
|
16
|
+
"import_bundle",
|
|
17
|
+
"scan_plaintext_secrets",
|
|
18
|
+
"validate_bundle",
|
|
19
|
+
]
|
|
20
|
+
__version__ = "0.3.1"
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Session bundle zip read/write."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import zipfile
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import quote, unquote
|
|
11
|
+
|
|
12
|
+
from session_bundle_kit.models import (
|
|
13
|
+
BUNDLE_VERSION,
|
|
14
|
+
BundleManifest,
|
|
15
|
+
IndexedDbMeta,
|
|
16
|
+
OriginStorage,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True)
|
|
21
|
+
class SessionBundle:
|
|
22
|
+
manifest: BundleManifest
|
|
23
|
+
cookies: list[dict[str, Any]] = field(default_factory=list)
|
|
24
|
+
origins: list[OriginStorage] = field(default_factory=list)
|
|
25
|
+
indexeddb: list[IndexedDbMeta] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
def to_playwright_storage_state(self) -> dict[str, Any]:
|
|
28
|
+
return {
|
|
29
|
+
"cookies": self.cookies,
|
|
30
|
+
"origins": [origin.to_dict() for origin in self.origins],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_playwright_storage_state(
|
|
35
|
+
cls,
|
|
36
|
+
payload: dict[str, Any],
|
|
37
|
+
*,
|
|
38
|
+
indexeddb: list[IndexedDbMeta] | None = None,
|
|
39
|
+
) -> SessionBundle:
|
|
40
|
+
cookies = payload.get("cookies", [])
|
|
41
|
+
origins_raw = payload.get("origins", [])
|
|
42
|
+
origins = [OriginStorage.from_dict(item) for item in origins_raw if isinstance(item, dict)]
|
|
43
|
+
idb = indexeddb or []
|
|
44
|
+
manifest = BundleManifest(
|
|
45
|
+
version=BUNDLE_VERSION,
|
|
46
|
+
source="playwright-storage-state",
|
|
47
|
+
cookie_count=len(cookies) if isinstance(cookies, list) else 0,
|
|
48
|
+
origin_count=len(origins),
|
|
49
|
+
indexeddb_count=len(idb),
|
|
50
|
+
)
|
|
51
|
+
return cls(
|
|
52
|
+
manifest=manifest,
|
|
53
|
+
cookies=cookies if isinstance(cookies, list) else [],
|
|
54
|
+
origins=origins,
|
|
55
|
+
indexeddb=idb,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def write_zip(self, path: Path | str) -> None:
|
|
59
|
+
target = Path(path)
|
|
60
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
with zipfile.ZipFile(target, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
62
|
+
zf.writestr("manifest.json", json.dumps(self.manifest.to_dict(), indent=2))
|
|
63
|
+
zf.writestr("cookies.json", json.dumps(self.cookies, indent=2))
|
|
64
|
+
for origin in self.origins:
|
|
65
|
+
name = _origin_path(origin.origin)
|
|
66
|
+
zf.writestr(name, json.dumps(origin.to_dict(), indent=2))
|
|
67
|
+
if self.indexeddb:
|
|
68
|
+
zf.writestr(
|
|
69
|
+
"indexeddb/metadata.json",
|
|
70
|
+
json.dumps([item.to_dict() for item in self.indexeddb], indent=2),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def read_zip(cls, path: Path | str) -> SessionBundle:
|
|
75
|
+
target = Path(path)
|
|
76
|
+
if not target.is_file():
|
|
77
|
+
raise FileNotFoundError(f"Bundle not found: {target}")
|
|
78
|
+
|
|
79
|
+
with zipfile.ZipFile(target, "r") as zf:
|
|
80
|
+
names = zf.namelist()
|
|
81
|
+
if "manifest.json" not in names:
|
|
82
|
+
raise ValueError("Bundle missing manifest.json")
|
|
83
|
+
manifest_raw = json.loads(zf.read("manifest.json"))
|
|
84
|
+
if not isinstance(manifest_raw, dict):
|
|
85
|
+
raise ValueError("manifest.json must be a JSON object")
|
|
86
|
+
if "version" not in manifest_raw:
|
|
87
|
+
raise ValueError("manifest.json missing required field 'version'")
|
|
88
|
+
manifest = BundleManifest.from_dict(manifest_raw)
|
|
89
|
+
cookies = json.loads(zf.read("cookies.json")) if "cookies.json" in names else []
|
|
90
|
+
if not isinstance(cookies, list):
|
|
91
|
+
raise ValueError("cookies.json must be a JSON array")
|
|
92
|
+
|
|
93
|
+
origins: list[OriginStorage] = []
|
|
94
|
+
for name in names:
|
|
95
|
+
if name.startswith("origins/") and name.endswith(".json"):
|
|
96
|
+
payload = json.loads(zf.read(name))
|
|
97
|
+
if isinstance(payload, dict):
|
|
98
|
+
origins.append(OriginStorage.from_dict(payload))
|
|
99
|
+
|
|
100
|
+
indexeddb: list[IndexedDbMeta] = []
|
|
101
|
+
if "indexeddb/metadata.json" in names:
|
|
102
|
+
raw = json.loads(zf.read("indexeddb/metadata.json"))
|
|
103
|
+
if isinstance(raw, list):
|
|
104
|
+
indexeddb = [
|
|
105
|
+
IndexedDbMeta.from_dict(item) for item in raw if isinstance(item, dict)
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
return cls(
|
|
109
|
+
manifest=manifest,
|
|
110
|
+
cookies=cookies,
|
|
111
|
+
origins=origins,
|
|
112
|
+
indexeddb=indexeddb,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _origin_path(origin: str) -> str:
|
|
117
|
+
safe = quote(origin, safe="")
|
|
118
|
+
return f"origins/{safe}.json"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def origin_from_path(path: str) -> str:
|
|
122
|
+
name = Path(path).stem
|
|
123
|
+
return unquote(name)
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Click CLI for session-bundle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from session_bundle_kit.diff import diff_bundles, format_diff
|
|
12
|
+
from session_bundle_kit.export import export_playwright_context
|
|
13
|
+
from session_bundle_kit.import_bundle import import_bundle
|
|
14
|
+
|
|
15
|
+
EXIT_OK = 0
|
|
16
|
+
EXIT_VALIDATION = 1
|
|
17
|
+
EXIT_ERROR = 2
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.group(invoke_without_command=True)
|
|
21
|
+
@click.version_option(package_name="session-bundle-kit")
|
|
22
|
+
@click.option("--show-deal", is_flag=True, help="Print Multilogin coupon info and exit.")
|
|
23
|
+
@click.pass_context
|
|
24
|
+
def main(ctx: click.Context, show_deal: bool) -> None:
|
|
25
|
+
"""Export, import, and diff browser session bundles."""
|
|
26
|
+
if show_deal:
|
|
27
|
+
from session_bundle_kit.deal import print_show_deal
|
|
28
|
+
|
|
29
|
+
print_show_deal()
|
|
30
|
+
ctx.exit(0)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@main.command("export")
|
|
34
|
+
@click.option(
|
|
35
|
+
"--playwright-context",
|
|
36
|
+
"context_path",
|
|
37
|
+
type=click.Path(exists=True, path_type=Path),
|
|
38
|
+
required=True,
|
|
39
|
+
)
|
|
40
|
+
@click.option("-o", "--output", "output_path", type=click.Path(path_type=Path), required=True)
|
|
41
|
+
def export_cmd(context_path: Path, output_path: Path) -> None:
|
|
42
|
+
"""Export Playwright storage state JSON to a session bundle zip."""
|
|
43
|
+
try:
|
|
44
|
+
bundle = export_playwright_context(context_path, output_path)
|
|
45
|
+
click.echo(
|
|
46
|
+
f"Exported {bundle.manifest.cookie_count} cookies, "
|
|
47
|
+
f"{bundle.manifest.origin_count} origins -> {output_path}"
|
|
48
|
+
)
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
raise click.ClickException(str(exc)) from exc
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@main.command("import")
|
|
54
|
+
@click.argument("bundle_path", type=click.Path(exists=True, path_type=Path))
|
|
55
|
+
@click.option(
|
|
56
|
+
"--dry-run/--apply",
|
|
57
|
+
"dry_run",
|
|
58
|
+
default=True,
|
|
59
|
+
show_default=True,
|
|
60
|
+
help="Validate only; never writes browser state (default).",
|
|
61
|
+
)
|
|
62
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
63
|
+
def import_cmd(bundle_path: Path, dry_run: bool, as_json: bool) -> None:
|
|
64
|
+
"""Validate or import a session bundle."""
|
|
65
|
+
report = import_bundle(bundle_path, dry_run=dry_run)
|
|
66
|
+
if as_json:
|
|
67
|
+
click.echo(json.dumps(report.to_dict(), indent=2))
|
|
68
|
+
else:
|
|
69
|
+
status = "VALID" if report.valid else "INVALID"
|
|
70
|
+
click.echo(f"{status}: {bundle_path}")
|
|
71
|
+
click.echo(
|
|
72
|
+
f" cookies={report.cookie_count} origins={report.origin_count} "
|
|
73
|
+
f"localStorage keys={report.local_storage_keys} "
|
|
74
|
+
f"sessionStorage keys={report.session_storage_keys} "
|
|
75
|
+
f"indexedDB meta={report.indexeddb_count}"
|
|
76
|
+
)
|
|
77
|
+
for issue in report.issues:
|
|
78
|
+
click.echo(f" issue: {issue}")
|
|
79
|
+
for warning in report.warnings:
|
|
80
|
+
click.echo(f" warn: {warning}", err=True)
|
|
81
|
+
if not report.valid:
|
|
82
|
+
sys.exit(EXIT_VALIDATION)
|
|
83
|
+
sys.exit(EXIT_OK)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@main.command("diff")
|
|
87
|
+
@click.argument("left_bundle", type=click.Path(exists=True, path_type=Path))
|
|
88
|
+
@click.argument("right_bundle", type=click.Path(exists=True, path_type=Path))
|
|
89
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
90
|
+
def diff_cmd(left_bundle: Path, right_bundle: Path, as_json: bool) -> None:
|
|
91
|
+
"""Diff two session bundle zip files."""
|
|
92
|
+
try:
|
|
93
|
+
result = diff_bundles(left_bundle, right_bundle)
|
|
94
|
+
click.echo(json.dumps(result.to_dict(), indent=2) if as_json else format_diff(result))
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
raise click.ClickException(str(exc)) from exc
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@main.command("mlx-pull")
|
|
100
|
+
@click.option("--profile-id", required=True, help="MLX profile UUID.")
|
|
101
|
+
@click.option(
|
|
102
|
+
"-o",
|
|
103
|
+
"--output",
|
|
104
|
+
"output_path",
|
|
105
|
+
type=click.Path(path_type=Path),
|
|
106
|
+
required=True,
|
|
107
|
+
help="Output bundle.zip (session-bundle-v1).",
|
|
108
|
+
)
|
|
109
|
+
@click.option("--folder-id", envvar="MLX_FOLDER_ID", help="MLX folder UUID (export API).")
|
|
110
|
+
@click.option("--token", envvar="MLX_TOKEN")
|
|
111
|
+
@click.option("--launcher-url", default=None, help="Launcher base (MLX_LAUNCHER_URL).")
|
|
112
|
+
@click.option("--no-unlock", is_flag=True, help="Skip Cloud API profile unlock.")
|
|
113
|
+
def mlx_pull_cmd(
|
|
114
|
+
profile_id: str,
|
|
115
|
+
output_path: Path,
|
|
116
|
+
folder_id: str | None,
|
|
117
|
+
token: str | None,
|
|
118
|
+
launcher_url: str | None,
|
|
119
|
+
no_unlock: bool,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Export MLX profile session cookies to a neutral bundle.zip."""
|
|
122
|
+
from session_bundle_kit.mlx import MlxBundleClient, launcher_url_from_env, require_mlx_extra
|
|
123
|
+
|
|
124
|
+
require_mlx_extra()
|
|
125
|
+
if not token:
|
|
126
|
+
raise click.ClickException("--token or MLX_TOKEN required.")
|
|
127
|
+
|
|
128
|
+
client = MlxBundleClient(
|
|
129
|
+
token=token,
|
|
130
|
+
launcher_base=launcher_url or launcher_url_from_env(),
|
|
131
|
+
)
|
|
132
|
+
try:
|
|
133
|
+
result = client.pull_bundle(
|
|
134
|
+
profile_id,
|
|
135
|
+
output_path,
|
|
136
|
+
folder_id=folder_id,
|
|
137
|
+
unlock=not no_unlock,
|
|
138
|
+
)
|
|
139
|
+
click.echo(json.dumps(result, indent=2))
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
raise click.ClickException(str(exc)) from exc
|
|
142
|
+
finally:
|
|
143
|
+
client.close()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@main.command("mlx-push")
|
|
147
|
+
@click.option("--profile-id", required=True, help="MLX profile UUID.")
|
|
148
|
+
@click.option(
|
|
149
|
+
"--bundle",
|
|
150
|
+
"bundle_path",
|
|
151
|
+
type=click.Path(exists=True, path_type=Path),
|
|
152
|
+
required=True,
|
|
153
|
+
)
|
|
154
|
+
@click.option("--token", envvar="MLX_TOKEN")
|
|
155
|
+
@click.option("--launcher-url", default=None, help="Launcher base (MLX_LAUNCHER_URL).")
|
|
156
|
+
@click.option(
|
|
157
|
+
"--dry-run",
|
|
158
|
+
is_flag=True,
|
|
159
|
+
help="Validate bundle and report import plan without calling MLX API.",
|
|
160
|
+
)
|
|
161
|
+
def mlx_push_cmd(
|
|
162
|
+
profile_id: str,
|
|
163
|
+
bundle_path: Path,
|
|
164
|
+
token: str | None,
|
|
165
|
+
launcher_url: str | None,
|
|
166
|
+
dry_run: bool,
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Push session-bundle-v1 cookies to an MLX profile (Launcher import + unlock)."""
|
|
169
|
+
from session_bundle_kit.mlx import MlxBundleClient, launcher_url_from_env, require_mlx_extra
|
|
170
|
+
|
|
171
|
+
require_mlx_extra()
|
|
172
|
+
if not dry_run and not token:
|
|
173
|
+
raise click.ClickException("--token or MLX_TOKEN required (or use --dry-run).")
|
|
174
|
+
|
|
175
|
+
client = MlxBundleClient(
|
|
176
|
+
token=token or "",
|
|
177
|
+
launcher_base=launcher_url or launcher_url_from_env(),
|
|
178
|
+
)
|
|
179
|
+
try:
|
|
180
|
+
result = client.push_bundle(profile_id, bundle_path, dry_run=dry_run)
|
|
181
|
+
click.echo(json.dumps(result, indent=2))
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
raise click.ClickException(str(exc)) from exc
|
|
184
|
+
finally:
|
|
185
|
+
client.close()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
if __name__ == "__main__":
|
|
189
|
+
main()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Multilogin partner coupon output for --show-deal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
SHOW_DEAL_TEXT = """Partner info (affiliate links — optional, not required for session-bundle)
|
|
8
|
+
|
|
9
|
+
Moving Playwright sessions into isolated profiles? MLX Launcher cookie import
|
|
10
|
+
fits after export/dry-run — not as a substitute for matching proxy/fingerprint.
|
|
11
|
+
|
|
12
|
+
Multilogin X — antidetect browser & cloud phone (verify eligibility before checkout)
|
|
13
|
+
|
|
14
|
+
SAAS50 — browser plans (eligible new purchases)
|
|
15
|
+
MIN50 — cloud phone (eligible new purchases)
|
|
16
|
+
|
|
17
|
+
https://multilogin.com/?ref=SAAS50
|
|
18
|
+
Coupons: https://anti-detect.github.io/
|
|
19
|
+
Scripts: https://t.me/Multilogin_Scripts_Bot
|
|
20
|
+
|
|
21
|
+
Disclosure: we may earn a commission. Offers change; confirm on vendor site."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def print_show_deal() -> None:
|
|
25
|
+
click.echo(SHOW_DEAL_TEXT)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Diff two session bundles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from session_bundle_kit.bundle import SessionBundle
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True)
|
|
13
|
+
class BundleDiff:
|
|
14
|
+
cookies_added: int = 0
|
|
15
|
+
cookies_removed: int = 0
|
|
16
|
+
cookies_changed: int = 0
|
|
17
|
+
cookie_domains_added: list[str] = field(default_factory=list)
|
|
18
|
+
cookie_domains_removed: list[str] = field(default_factory=list)
|
|
19
|
+
cookie_domains_changed: list[str] = field(default_factory=list)
|
|
20
|
+
origins_added: list[str] = field(default_factory=list)
|
|
21
|
+
origins_removed: list[str] = field(default_factory=list)
|
|
22
|
+
storage_keys_changed: list[str] = field(default_factory=list)
|
|
23
|
+
indexeddb_changed: list[str] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict[str, Any]:
|
|
26
|
+
return {
|
|
27
|
+
"cookies": {
|
|
28
|
+
"added": self.cookies_added,
|
|
29
|
+
"removed": self.cookies_removed,
|
|
30
|
+
"changed": self.cookies_changed,
|
|
31
|
+
"domains_added": self.cookie_domains_added,
|
|
32
|
+
"domains_removed": self.cookie_domains_removed,
|
|
33
|
+
"domains_changed": self.cookie_domains_changed,
|
|
34
|
+
},
|
|
35
|
+
"origins": {
|
|
36
|
+
"added": self.origins_added,
|
|
37
|
+
"removed": self.origins_removed,
|
|
38
|
+
},
|
|
39
|
+
"storage_keys_changed": self.storage_keys_changed,
|
|
40
|
+
"indexeddb_changed": self.indexeddb_changed,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _cookie_key(cookie: dict[str, Any]) -> tuple[str, str, str]:
|
|
45
|
+
return (
|
|
46
|
+
str(cookie.get("domain", "")),
|
|
47
|
+
str(cookie.get("path", "/")),
|
|
48
|
+
str(cookie.get("name", "")),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _cookie_domain(cookie: dict[str, Any]) -> str:
|
|
53
|
+
return str(cookie.get("domain", ""))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def diff_bundles(left_path: Path | str, right_path: Path | str) -> BundleDiff:
|
|
57
|
+
left = SessionBundle.read_zip(left_path)
|
|
58
|
+
right = SessionBundle.read_zip(right_path)
|
|
59
|
+
result = BundleDiff()
|
|
60
|
+
|
|
61
|
+
left_cookies = {_cookie_key(c): c for c in left.cookies}
|
|
62
|
+
right_cookies = {_cookie_key(c): c for c in right.cookies}
|
|
63
|
+
added_keys = set(right_cookies) - set(left_cookies)
|
|
64
|
+
removed_keys = set(left_cookies) - set(right_cookies)
|
|
65
|
+
result.cookies_added = len(added_keys)
|
|
66
|
+
result.cookies_removed = len(removed_keys)
|
|
67
|
+
result.cookie_domains_added = sorted({_cookie_domain(right_cookies[k]) for k in added_keys})
|
|
68
|
+
result.cookie_domains_removed = sorted({_cookie_domain(left_cookies[k]) for k in removed_keys})
|
|
69
|
+
|
|
70
|
+
changed_domains: set[str] = set()
|
|
71
|
+
for key in set(left_cookies) & set(right_cookies):
|
|
72
|
+
if left_cookies[key].get("value") != right_cookies[key].get("value"):
|
|
73
|
+
result.cookies_changed += 1
|
|
74
|
+
changed_domains.add(_cookie_domain(left_cookies[key]))
|
|
75
|
+
result.cookie_domains_changed = sorted(changed_domains)
|
|
76
|
+
|
|
77
|
+
left_origins = {o.origin: o for o in left.origins}
|
|
78
|
+
right_origins = {o.origin: o for o in right.origins}
|
|
79
|
+
result.origins_added = sorted(set(right_origins) - set(left_origins))
|
|
80
|
+
result.origins_removed = sorted(set(left_origins) - set(right_origins))
|
|
81
|
+
|
|
82
|
+
for origin in set(left_origins) & set(right_origins):
|
|
83
|
+
lo = left_origins[origin]
|
|
84
|
+
ro = right_origins[origin]
|
|
85
|
+
lk = {e.name: e.value for e in lo.local_storage + lo.session_storage}
|
|
86
|
+
rk = {e.name: e.value for e in ro.local_storage + ro.session_storage}
|
|
87
|
+
for name in set(lk) | set(rk):
|
|
88
|
+
if lk.get(name) != rk.get(name):
|
|
89
|
+
result.storage_keys_changed.append(f"{origin}::{name}")
|
|
90
|
+
|
|
91
|
+
left_idb = {(d.origin, d.database_name): d for d in left.indexeddb}
|
|
92
|
+
right_idb = {(d.origin, d.database_name): d for d in right.indexeddb}
|
|
93
|
+
for key in set(left_idb) | set(right_idb):
|
|
94
|
+
if left_idb.get(key) != right_idb.get(key):
|
|
95
|
+
result.indexeddb_changed.append(f"{key[0]}::{key[1]}")
|
|
96
|
+
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def format_diff(diff: BundleDiff) -> str:
|
|
101
|
+
lines = [
|
|
102
|
+
"Session Bundle Diff",
|
|
103
|
+
"===================",
|
|
104
|
+
f"Cookies: +{diff.cookies_added} -{diff.cookies_removed} ~{diff.cookies_changed}",
|
|
105
|
+
f" domains added: {', '.join(diff.cookie_domains_added) or '(none)'}",
|
|
106
|
+
f" domains removed: {', '.join(diff.cookie_domains_removed) or '(none)'}",
|
|
107
|
+
f" domains changed: {', '.join(diff.cookie_domains_changed) or '(none)'}",
|
|
108
|
+
f"Origins added: {', '.join(diff.origins_added) or '(none)'}",
|
|
109
|
+
f"Origins removed: {', '.join(diff.origins_removed) or '(none)'}",
|
|
110
|
+
f"Storage keys changed: {len(diff.storage_keys_changed)}",
|
|
111
|
+
f"IndexedDB changed: {len(diff.indexeddb_changed)}",
|
|
112
|
+
]
|
|
113
|
+
if diff.storage_keys_changed:
|
|
114
|
+
lines.append("")
|
|
115
|
+
lines.append("Storage changes:")
|
|
116
|
+
for item in diff.storage_keys_changed[:20]:
|
|
117
|
+
lines.append(f" - {item}")
|
|
118
|
+
if len(diff.storage_keys_changed) > 20:
|
|
119
|
+
lines.append(f" ... and {len(diff.storage_keys_changed) - 20} more")
|
|
120
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Export Playwright storage state to bundle zip."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from session_bundle_kit.bundle import SessionBundle
|
|
10
|
+
from session_bundle_kit.models import IndexedDbMeta
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_playwright_context(path: Path | str) -> dict[str, Any]:
|
|
14
|
+
payload = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
15
|
+
if not isinstance(payload, dict):
|
|
16
|
+
raise ValueError("Playwright storage state must be a JSON object")
|
|
17
|
+
return payload
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def export_playwright_context(
|
|
21
|
+
context_path: Path | str,
|
|
22
|
+
output_path: Path | str,
|
|
23
|
+
*,
|
|
24
|
+
indexeddb_metadata: list[IndexedDbMeta] | None = None,
|
|
25
|
+
) -> SessionBundle:
|
|
26
|
+
"""Create a session bundle zip from Playwright storage state JSON."""
|
|
27
|
+
payload = load_playwright_context(context_path)
|
|
28
|
+
bundle = SessionBundle.from_playwright_storage_state(
|
|
29
|
+
payload,
|
|
30
|
+
indexeddb=indexeddb_metadata,
|
|
31
|
+
)
|
|
32
|
+
bundle.write_zip(output_path)
|
|
33
|
+
return bundle
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Import and validate session bundles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from session_bundle_kit.bundle import SessionBundle
|
|
11
|
+
from session_bundle_kit.models import BUNDLE_SPEC, BUNDLE_VERSION, SUPPORTED_SPECS
|
|
12
|
+
from session_bundle_kit.security import scan_plaintext_secrets
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class ImportReport:
|
|
17
|
+
valid: bool
|
|
18
|
+
bundle_path: str
|
|
19
|
+
cookie_count: int = 0
|
|
20
|
+
origin_count: int = 0
|
|
21
|
+
indexeddb_count: int = 0
|
|
22
|
+
local_storage_keys: int = 0
|
|
23
|
+
session_storage_keys: int = 0
|
|
24
|
+
issues: list[str] = field(default_factory=list)
|
|
25
|
+
warnings: list[str] = field(default_factory=list)
|
|
26
|
+
dry_run: bool = True
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> dict[str, Any]:
|
|
29
|
+
return {
|
|
30
|
+
"valid": self.valid,
|
|
31
|
+
"bundle_path": self.bundle_path,
|
|
32
|
+
"dry_run": self.dry_run,
|
|
33
|
+
"counts": {
|
|
34
|
+
"cookies": self.cookie_count,
|
|
35
|
+
"origins": self.origin_count,
|
|
36
|
+
"indexeddb": self.indexeddb_count,
|
|
37
|
+
"local_storage_keys": self.local_storage_keys,
|
|
38
|
+
"session_storage_keys": self.session_storage_keys,
|
|
39
|
+
},
|
|
40
|
+
"issues": self.issues,
|
|
41
|
+
"warnings": self.warnings,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def validate_bundle(path: Path | str) -> ImportReport:
|
|
46
|
+
report = ImportReport(valid=True, bundle_path=str(path), dry_run=True)
|
|
47
|
+
try:
|
|
48
|
+
bundle = SessionBundle.read_zip(path)
|
|
49
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
50
|
+
report.valid = False
|
|
51
|
+
report.issues.append(str(exc))
|
|
52
|
+
return report
|
|
53
|
+
|
|
54
|
+
_validate_manifest(bundle, report)
|
|
55
|
+
|
|
56
|
+
report.cookie_count = len(bundle.cookies)
|
|
57
|
+
report.origin_count = len(bundle.origins)
|
|
58
|
+
report.indexeddb_count = len(bundle.indexeddb)
|
|
59
|
+
report.local_storage_keys = sum(len(o.local_storage) for o in bundle.origins)
|
|
60
|
+
report.session_storage_keys = sum(len(o.session_storage) for o in bundle.origins)
|
|
61
|
+
|
|
62
|
+
if bundle.manifest.cookie_count != report.cookie_count:
|
|
63
|
+
report.issues.append(
|
|
64
|
+
f"manifest cookie_count={bundle.manifest.cookie_count} "
|
|
65
|
+
f"but cookies.json has {report.cookie_count}"
|
|
66
|
+
)
|
|
67
|
+
if bundle.manifest.origin_count != report.origin_count:
|
|
68
|
+
report.issues.append(
|
|
69
|
+
f"manifest origin_count={bundle.manifest.origin_count} "
|
|
70
|
+
f"but found {report.origin_count} origin files"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
for cookie in bundle.cookies:
|
|
74
|
+
if not cookie.get("name") or not cookie.get("domain"):
|
|
75
|
+
report.issues.append(f"cookie missing name/domain: {cookie!r}")
|
|
76
|
+
report.valid = False
|
|
77
|
+
|
|
78
|
+
report.warnings.extend(scan_plaintext_secrets(bundle))
|
|
79
|
+
if report.issues:
|
|
80
|
+
report.valid = False
|
|
81
|
+
return report
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _validate_manifest(bundle: SessionBundle, report: ImportReport) -> None:
|
|
85
|
+
manifest = bundle.manifest
|
|
86
|
+
if manifest.spec not in SUPPORTED_SPECS:
|
|
87
|
+
report.warnings.append(
|
|
88
|
+
f"unknown bundle spec {manifest.spec!r} (supported: {sorted(SUPPORTED_SPECS)})"
|
|
89
|
+
)
|
|
90
|
+
if manifest.version > BUNDLE_VERSION:
|
|
91
|
+
report.warnings.append(
|
|
92
|
+
f"bundle version {manifest.version} is newer than toolkit v{BUNDLE_VERSION}"
|
|
93
|
+
)
|
|
94
|
+
elif manifest.version < BUNDLE_VERSION:
|
|
95
|
+
report.warnings.append(
|
|
96
|
+
f"bundle version {manifest.version} is older than current spec {BUNDLE_SPEC}"
|
|
97
|
+
)
|
|
98
|
+
if manifest.spec != BUNDLE_SPEC and manifest.version == BUNDLE_VERSION:
|
|
99
|
+
report.warnings.append(f"expected spec {BUNDLE_SPEC!r}, got {manifest.spec!r}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def import_bundle(path: Path | str, *, dry_run: bool = True) -> ImportReport:
|
|
103
|
+
"""Validate a bundle; dry-run performs no writes (default)."""
|
|
104
|
+
report = validate_bundle(path)
|
|
105
|
+
report.dry_run = dry_run
|
|
106
|
+
if not dry_run:
|
|
107
|
+
report.warnings.append(
|
|
108
|
+
"Live browser import not implemented; use Playwright storage_state API"
|
|
109
|
+
)
|
|
110
|
+
return report
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""MLX profile session export (pull) and bundle import (push)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from session_bundle_kit.bundle import SessionBundle
|
|
12
|
+
from session_bundle_kit.import_bundle import validate_bundle
|
|
13
|
+
from session_bundle_kit.models import BUNDLE_SPEC, BUNDLE_VERSION, BundleManifest
|
|
14
|
+
|
|
15
|
+
_MLX_HINT = "Install MLX support with: pip install session-bundle-kit[mlx]"
|
|
16
|
+
DEFAULT_CLOUD_BASE = "https://api.multilogin.com"
|
|
17
|
+
DEFAULT_LAUNCHER_BASE = "https://launcher.mlx.yt:45001"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def require_mlx_extra() -> None:
|
|
21
|
+
if importlib.util.find_spec("httpx") is None:
|
|
22
|
+
raise ImportError(_MLX_HINT)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def token_from_env() -> str | None:
|
|
26
|
+
return os.environ.get("MLX_TOKEN")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def launcher_url_from_env() -> str:
|
|
30
|
+
return (
|
|
31
|
+
os.environ.get("MLX_LAUNCHER_URL")
|
|
32
|
+
or os.environ.get("MLX_LAUNCHER_HOST")
|
|
33
|
+
or DEFAULT_LAUNCHER_BASE
|
|
34
|
+
).rstrip("/")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def cloud_url_from_env() -> str:
|
|
38
|
+
return os.environ.get("MLX_CLOUD_URL", DEFAULT_CLOUD_BASE).rstrip("/")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cookies_to_mlx(cookies: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
42
|
+
"""Playwright storage_state cookies → MLX Launcher import format."""
|
|
43
|
+
payload: list[dict[str, Any]] = []
|
|
44
|
+
for cookie in cookies:
|
|
45
|
+
item: dict[str, Any] = {
|
|
46
|
+
"domain": cookie.get("domain", ""),
|
|
47
|
+
"name": cookie.get("name", ""),
|
|
48
|
+
"value": cookie.get("value", ""),
|
|
49
|
+
"path": cookie.get("path", "/"),
|
|
50
|
+
"httpOnly": bool(cookie.get("httpOnly", False)),
|
|
51
|
+
"secure": bool(cookie.get("secure", False)),
|
|
52
|
+
}
|
|
53
|
+
expires = cookie.get("expires")
|
|
54
|
+
if expires not in (None, -1, "-1"):
|
|
55
|
+
item["expirationDate"] = expires
|
|
56
|
+
if cookie.get("sameSite"):
|
|
57
|
+
item["sameSite"] = cookie["sameSite"]
|
|
58
|
+
payload.append(item)
|
|
59
|
+
return payload
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def cookies_from_mlx(cookies: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
63
|
+
"""MLX export cookies → Playwright storage_state cookie list."""
|
|
64
|
+
payload: list[dict[str, Any]] = []
|
|
65
|
+
for cookie in cookies:
|
|
66
|
+
item: dict[str, Any] = {
|
|
67
|
+
"domain": cookie.get("domain", ""),
|
|
68
|
+
"name": cookie.get("name", ""),
|
|
69
|
+
"value": cookie.get("value", ""),
|
|
70
|
+
"path": cookie.get("path", "/"),
|
|
71
|
+
"httpOnly": bool(cookie.get("httpOnly", False)),
|
|
72
|
+
"secure": bool(cookie.get("secure", False)),
|
|
73
|
+
}
|
|
74
|
+
expires = cookie.get("expirationDate", cookie.get("expires"))
|
|
75
|
+
if expires not in (None, -1, "-1"):
|
|
76
|
+
item["expires"] = expires
|
|
77
|
+
if cookie.get("sameSite"):
|
|
78
|
+
item["sameSite"] = cookie["sameSite"]
|
|
79
|
+
payload.append(item)
|
|
80
|
+
return payload
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_mlx_cookie_export(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
84
|
+
"""Normalize MLX ``/cookies/export`` data.cookies (array or JSON string)."""
|
|
85
|
+
raw = data.get("cookies")
|
|
86
|
+
if raw is None:
|
|
87
|
+
return []
|
|
88
|
+
if isinstance(raw, str):
|
|
89
|
+
parsed = json.loads(raw)
|
|
90
|
+
if isinstance(parsed, str):
|
|
91
|
+
parsed = json.loads(parsed)
|
|
92
|
+
raw = parsed
|
|
93
|
+
if not isinstance(raw, list):
|
|
94
|
+
raise RuntimeError("MLX export cookies must be a JSON array")
|
|
95
|
+
mlx_cookies = [item for item in raw if isinstance(item, dict)]
|
|
96
|
+
return cookies_from_mlx(mlx_cookies)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def bundle_from_mlx_cookies(
|
|
100
|
+
cookies: list[dict[str, Any]],
|
|
101
|
+
*,
|
|
102
|
+
profile_id: str,
|
|
103
|
+
) -> SessionBundle:
|
|
104
|
+
"""Build neutral session-bundle-v1 from MLX-exported cookies only."""
|
|
105
|
+
manifest = BundleManifest(
|
|
106
|
+
version=BUNDLE_VERSION,
|
|
107
|
+
spec=BUNDLE_SPEC,
|
|
108
|
+
source="mlx-profile-export",
|
|
109
|
+
cookie_count=len(cookies),
|
|
110
|
+
origin_count=0,
|
|
111
|
+
indexeddb_count=0,
|
|
112
|
+
)
|
|
113
|
+
return SessionBundle(
|
|
114
|
+
manifest=manifest,
|
|
115
|
+
cookies=cookies,
|
|
116
|
+
origins=[],
|
|
117
|
+
indexeddb=[],
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class MlxBundleClient:
|
|
122
|
+
"""Pull (export) and push (import) session bundles via MLX APIs."""
|
|
123
|
+
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
*,
|
|
127
|
+
token: str,
|
|
128
|
+
cloud_base: str | None = None,
|
|
129
|
+
launcher_base: str | None = None,
|
|
130
|
+
timeout: float = 60.0,
|
|
131
|
+
transport: Any | None = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
require_mlx_extra()
|
|
134
|
+
import httpx # noqa: PLC0415
|
|
135
|
+
|
|
136
|
+
cloud_kwargs: dict[str, Any] = {
|
|
137
|
+
"base_url": (cloud_base or cloud_url_from_env()).rstrip("/"),
|
|
138
|
+
"timeout": timeout,
|
|
139
|
+
}
|
|
140
|
+
launcher_kwargs: dict[str, Any] = {
|
|
141
|
+
"base_url": (launcher_base or launcher_url_from_env()).rstrip("/"),
|
|
142
|
+
"timeout": timeout,
|
|
143
|
+
}
|
|
144
|
+
if transport is not None:
|
|
145
|
+
cloud_kwargs["transport"] = transport
|
|
146
|
+
launcher_kwargs["transport"] = transport
|
|
147
|
+
|
|
148
|
+
self._cloud = httpx.Client(**cloud_kwargs)
|
|
149
|
+
self._launcher = httpx.Client(**launcher_kwargs)
|
|
150
|
+
self._token = token
|
|
151
|
+
|
|
152
|
+
def close(self) -> None:
|
|
153
|
+
self._cloud.close()
|
|
154
|
+
self._launcher.close()
|
|
155
|
+
|
|
156
|
+
def _headers(self) -> dict[str, str]:
|
|
157
|
+
return {
|
|
158
|
+
"Accept": "application/json",
|
|
159
|
+
"Content-Type": "application/json",
|
|
160
|
+
"Authorization": f"Bearer {self._token}",
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
def unlock_profile(self, profile_id: str) -> dict[str, Any]:
|
|
164
|
+
response = self._cloud.get(
|
|
165
|
+
"/profile/unlock",
|
|
166
|
+
headers=self._headers(),
|
|
167
|
+
params={"profile_ids": profile_id},
|
|
168
|
+
)
|
|
169
|
+
return _check(response)
|
|
170
|
+
|
|
171
|
+
def export_cookies(
|
|
172
|
+
self,
|
|
173
|
+
profile_id: str,
|
|
174
|
+
*,
|
|
175
|
+
folder_id: str | None = None,
|
|
176
|
+
) -> list[dict[str, Any]]:
|
|
177
|
+
body: dict[str, str] = {"profile_id": profile_id}
|
|
178
|
+
if folder_id:
|
|
179
|
+
body["folder_id"] = folder_id
|
|
180
|
+
response = self._launcher.post(
|
|
181
|
+
"/api/v1/cookies/export",
|
|
182
|
+
headers=self._headers(),
|
|
183
|
+
json=body,
|
|
184
|
+
)
|
|
185
|
+
data = _check(response)
|
|
186
|
+
return parse_mlx_cookie_export(data)
|
|
187
|
+
|
|
188
|
+
def import_cookies(self, profile_id: str, cookies: list[dict[str, Any]]) -> dict[str, Any]:
|
|
189
|
+
response = self._launcher.post(
|
|
190
|
+
"/api/v1/cookies/import",
|
|
191
|
+
headers=self._headers(),
|
|
192
|
+
json={"profile_id": profile_id, "cookies": cookies},
|
|
193
|
+
)
|
|
194
|
+
return _check(response)
|
|
195
|
+
|
|
196
|
+
def pull_bundle(
|
|
197
|
+
self,
|
|
198
|
+
profile_id: str,
|
|
199
|
+
output_path: Path | str,
|
|
200
|
+
*,
|
|
201
|
+
folder_id: str | None = None,
|
|
202
|
+
unlock: bool = True,
|
|
203
|
+
) -> dict[str, Any]:
|
|
204
|
+
"""Export MLX profile cookies into a session-bundle-v1 zip."""
|
|
205
|
+
unlock_result: dict[str, Any] | None = None
|
|
206
|
+
if unlock:
|
|
207
|
+
unlock_result = self.unlock_profile(profile_id)
|
|
208
|
+
|
|
209
|
+
cookies = self.export_cookies(profile_id, folder_id=folder_id)
|
|
210
|
+
bundle = bundle_from_mlx_cookies(cookies, profile_id=profile_id)
|
|
211
|
+
bundle.write_zip(output_path)
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
"profile_id": profile_id,
|
|
215
|
+
"output": str(output_path),
|
|
216
|
+
"spec": BUNDLE_SPEC,
|
|
217
|
+
"cookie_count": len(cookies),
|
|
218
|
+
"origin_count": 0,
|
|
219
|
+
"indexeddb_metadata": 0,
|
|
220
|
+
"unlock": unlock_result,
|
|
221
|
+
"note": (
|
|
222
|
+
"MLX export provides cookies only; localStorage/sessionStorage/IDB "
|
|
223
|
+
"are not included in Launcher cookie export"
|
|
224
|
+
),
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
def push_bundle(
|
|
228
|
+
self,
|
|
229
|
+
profile_id: str,
|
|
230
|
+
bundle_path: Path | str,
|
|
231
|
+
*,
|
|
232
|
+
dry_run: bool = False,
|
|
233
|
+
) -> dict[str, Any]:
|
|
234
|
+
"""Import session-bundle-v1 cookies into an MLX profile."""
|
|
235
|
+
report = validate_bundle(bundle_path)
|
|
236
|
+
bundle = SessionBundle.read_zip(bundle_path)
|
|
237
|
+
mlx_cookies = cookies_to_mlx(bundle.cookies)
|
|
238
|
+
|
|
239
|
+
result: dict[str, Any] = {
|
|
240
|
+
"profile_id": profile_id,
|
|
241
|
+
"bundle_path": str(bundle_path),
|
|
242
|
+
"spec": bundle.manifest.spec,
|
|
243
|
+
"valid": report.valid,
|
|
244
|
+
"cookie_count": len(mlx_cookies),
|
|
245
|
+
"origin_count": len(bundle.origins),
|
|
246
|
+
"indexeddb_metadata": len(bundle.indexeddb),
|
|
247
|
+
"dry_run": dry_run,
|
|
248
|
+
"issues": list(report.issues),
|
|
249
|
+
"warnings": list(report.warnings),
|
|
250
|
+
"note": (
|
|
251
|
+
"localStorage/sessionStorage/IDB are manifest-only; cookies pushed via Launcher"
|
|
252
|
+
),
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if not report.valid:
|
|
256
|
+
raise RuntimeError(f"Bundle validation failed: {', '.join(report.issues)}")
|
|
257
|
+
|
|
258
|
+
if dry_run:
|
|
259
|
+
result["would_import_cookies"] = len(mlx_cookies)
|
|
260
|
+
return result
|
|
261
|
+
|
|
262
|
+
unlock = self.unlock_profile(profile_id)
|
|
263
|
+
imported = self.import_cookies(profile_id, mlx_cookies)
|
|
264
|
+
result["unlock"] = unlock
|
|
265
|
+
result["import"] = imported
|
|
266
|
+
return result
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# Backward-compatible alias
|
|
270
|
+
MlxBundlePusher = MlxBundleClient
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _check(response: Any) -> dict[str, Any]:
|
|
274
|
+
body = response.json()
|
|
275
|
+
if response.status_code >= 400:
|
|
276
|
+
status = body.get("status", {}) if isinstance(body, dict) else {}
|
|
277
|
+
message = status.get("message") if isinstance(status, dict) else response.text
|
|
278
|
+
raise RuntimeError(message or f"HTTP {response.status_code}")
|
|
279
|
+
if isinstance(body, dict):
|
|
280
|
+
status = body.get("status", {})
|
|
281
|
+
if isinstance(status, dict) and status.get("error_code"):
|
|
282
|
+
raise RuntimeError(str(status.get("message") or status["error_code"]))
|
|
283
|
+
data = body.get("data")
|
|
284
|
+
return data if isinstance(data, dict) else {"raw": data}
|
|
285
|
+
return {"raw": body}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Bundle data models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
BUNDLE_VERSION = 1
|
|
9
|
+
BUNDLE_SPEC = "session-bundle-v1"
|
|
10
|
+
SUPPORTED_SPECS = frozenset({BUNDLE_SPEC})
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class StorageEntry:
|
|
15
|
+
name: str
|
|
16
|
+
value: str
|
|
17
|
+
|
|
18
|
+
def to_dict(self) -> dict[str, str]:
|
|
19
|
+
return {"name": self.name, "value": self.value}
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_dict(cls, data: dict[str, Any]) -> StorageEntry:
|
|
23
|
+
return cls(name=str(data.get("name", "")), value=str(data.get("value", "")))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class OriginStorage:
|
|
28
|
+
origin: str
|
|
29
|
+
local_storage: list[StorageEntry] = field(default_factory=list)
|
|
30
|
+
session_storage: list[StorageEntry] = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> dict[str, Any]:
|
|
33
|
+
return {
|
|
34
|
+
"origin": self.origin,
|
|
35
|
+
"localStorage": [e.to_dict() for e in self.local_storage],
|
|
36
|
+
"sessionStorage": [e.to_dict() for e in self.session_storage],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_dict(cls, data: dict[str, Any]) -> OriginStorage:
|
|
41
|
+
return cls(
|
|
42
|
+
origin=str(data.get("origin", "")),
|
|
43
|
+
local_storage=[
|
|
44
|
+
StorageEntry.from_dict(item)
|
|
45
|
+
for item in data.get("localStorage", [])
|
|
46
|
+
if isinstance(item, dict)
|
|
47
|
+
],
|
|
48
|
+
session_storage=[
|
|
49
|
+
StorageEntry.from_dict(item)
|
|
50
|
+
for item in data.get("sessionStorage", [])
|
|
51
|
+
if isinstance(item, dict)
|
|
52
|
+
],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class IndexedDbMeta:
|
|
58
|
+
origin: str
|
|
59
|
+
database_name: str
|
|
60
|
+
version: int | None = None
|
|
61
|
+
object_stores: list[str] = field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> dict[str, Any]:
|
|
64
|
+
payload: dict[str, Any] = {
|
|
65
|
+
"origin": self.origin,
|
|
66
|
+
"database_name": self.database_name,
|
|
67
|
+
}
|
|
68
|
+
if self.version is not None:
|
|
69
|
+
payload["version"] = self.version
|
|
70
|
+
if self.object_stores:
|
|
71
|
+
payload["object_stores"] = self.object_stores
|
|
72
|
+
return payload
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def from_dict(cls, data: dict[str, Any]) -> IndexedDbMeta:
|
|
76
|
+
stores = data.get("object_stores", [])
|
|
77
|
+
return cls(
|
|
78
|
+
origin=str(data.get("origin", "")),
|
|
79
|
+
database_name=str(data.get("database_name", "")),
|
|
80
|
+
version=data.get("version"),
|
|
81
|
+
object_stores=[str(s) for s in stores] if isinstance(stores, list) else [],
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(slots=True)
|
|
86
|
+
class BundleManifest:
|
|
87
|
+
version: int = BUNDLE_VERSION
|
|
88
|
+
spec: str = BUNDLE_SPEC
|
|
89
|
+
source: str = "playwright-storage-state"
|
|
90
|
+
cookie_count: int = 0
|
|
91
|
+
origin_count: int = 0
|
|
92
|
+
indexeddb_count: int = 0
|
|
93
|
+
|
|
94
|
+
def to_dict(self) -> dict[str, Any]:
|
|
95
|
+
return {
|
|
96
|
+
"spec": self.spec,
|
|
97
|
+
"version": self.version,
|
|
98
|
+
"source": self.source,
|
|
99
|
+
"cookie_count": self.cookie_count,
|
|
100
|
+
"origin_count": self.origin_count,
|
|
101
|
+
"indexeddb_count": self.indexeddb_count,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def from_dict(cls, data: dict[str, Any]) -> BundleManifest:
|
|
106
|
+
return cls(
|
|
107
|
+
version=int(data.get("version", BUNDLE_VERSION)),
|
|
108
|
+
spec=str(data.get("spec", BUNDLE_SPEC)),
|
|
109
|
+
source=str(data.get("source", "unknown")),
|
|
110
|
+
cookie_count=int(data.get("cookie_count", 0)),
|
|
111
|
+
origin_count=int(data.get("origin_count", 0)),
|
|
112
|
+
indexeddb_count=int(data.get("indexeddb_count", 0)),
|
|
113
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Security checks for session bundle contents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from session_bundle_kit.bundle import SessionBundle
|
|
8
|
+
|
|
9
|
+
_SECRET_KEY_RE = re.compile(
|
|
10
|
+
r"(password|passwd|pwd|passphrase|secret|credential|api[_-]?key|auth[_-]?token)",
|
|
11
|
+
re.IGNORECASE,
|
|
12
|
+
)
|
|
13
|
+
_INLINE_PASSWORD_RE = re.compile(
|
|
14
|
+
r'["\']?(?:password|passwd|pwd)["\']?\s*[:=]\s*["\']?[^"\']{4,}',
|
|
15
|
+
re.IGNORECASE,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def scan_plaintext_secrets(bundle: SessionBundle) -> list[str]:
|
|
20
|
+
"""Return warnings for likely plaintext secrets in web storage."""
|
|
21
|
+
warnings: list[str] = []
|
|
22
|
+
for origin in bundle.origins:
|
|
23
|
+
for store, entries in (
|
|
24
|
+
("localStorage", origin.local_storage),
|
|
25
|
+
("sessionStorage", origin.session_storage),
|
|
26
|
+
):
|
|
27
|
+
for entry in entries:
|
|
28
|
+
if _SECRET_KEY_RE.search(entry.name):
|
|
29
|
+
warnings.append(
|
|
30
|
+
f"{store} key {entry.name!r} on {origin.origin} "
|
|
31
|
+
"may store a plaintext password or secret"
|
|
32
|
+
)
|
|
33
|
+
elif _INLINE_PASSWORD_RE.search(entry.value):
|
|
34
|
+
warnings.append(
|
|
35
|
+
f"{store} value on {origin.origin} (key {entry.name!r}) "
|
|
36
|
+
"looks like embedded credential JSON"
|
|
37
|
+
)
|
|
38
|
+
return warnings
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: session-bundle-kit
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: Playwright storage_state zip export — backup sessions, diff bundles, MLX cookie pull/push. CLI: session-bundle.
|
|
5
|
+
Project-URL: Homepage, https://github.com/session-bundle-kit/session-bundle-kit
|
|
6
|
+
Project-URL: Documentation, https://github.com/session-bundle-kit/session-bundle-kit#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/session-bundle-kit/session-bundle-kit
|
|
8
|
+
Project-URL: Issues, https://github.com/session-bundle-kit/session-bundle-kit/issues
|
|
9
|
+
Author: session-bundle-kit contributors
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: browser-backup,browser-session-migration,chrome-136,context-backup,cookie-export,indexeddb-manifest,localstorage-backup,multilogin-cookies,playwright-storage-state,portable-session,session-bundle,session-diff,session-export,session-restore,storage-state,zip-export
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
|
|
23
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: click>=8.1
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-httpx>=0.34; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
32
|
+
Provides-Extra: mlx
|
|
33
|
+
Requires-Dist: httpx>=0.27; extra == 'mlx'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# session-bundle-kit
|
|
37
|
+
|
|
38
|
+
**Playwright session export & backup** — zip storage_state cookies and localStorage; MLX cookie pull/push for profile migration.
|
|
39
|
+
|
|
40
|
+
[](https://pypi.org/project/session-bundle-kit/)
|
|
41
|
+
[](https://pypi.org/project/session-bundle-kit/)
|
|
42
|
+
[](https://pypi.org/project/session-bundle-kit/)
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install session-bundle-kit
|
|
46
|
+
session-bundle export state.json -o bundle.zip
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
CLI: **`session-bundle`** · Python **3.10+** · optional **`[mlx]`** for Launcher helpers
|
|
50
|
+
|
|
51
|
+
**Playwright session export & backup** — zip `storage_state` cookies and localStorage; MLX cookie pull/push for profile migration.
|
|
52
|
+
|
|
53
|
+
Export and compare **browser session bundles** — cookies, `localStorage`, `sessionStorage`, and IndexedDB metadata — from Playwright storage state JSON.
|
|
54
|
+
|
|
55
|
+
Pure Python zip format. Optional MLX push for cookie import into antidetect profiles.
|
|
56
|
+
|
|
57
|
+
## Problem
|
|
58
|
+
|
|
59
|
+
Playwright `storage_state.json` is a single file mixing cookies and per-origin storage. Teams need:
|
|
60
|
+
|
|
61
|
+
- Portable **zip bundles** with a manifest for CI and backups
|
|
62
|
+
- **Dry-run validation** before restoring sessions
|
|
63
|
+
- **Diff** between two session snapshots
|
|
64
|
+
- Push cookies into **MLX profiles** after export
|
|
65
|
+
|
|
66
|
+
## Install
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pip install session-bundle-kit
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
MLX profile push:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pip install session-bundle-kit[mlx]
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Quick start
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# Export Playwright storage state -> bundle zip
|
|
82
|
+
session-bundle export --playwright-context ./state.json -o bundle.zip
|
|
83
|
+
|
|
84
|
+
# Validate bundle (dry-run default)
|
|
85
|
+
session-bundle import bundle.zip --dry-run
|
|
86
|
+
|
|
87
|
+
# Compare two bundles
|
|
88
|
+
session-bundle diff bundle_a.zip bundle_b.zip
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Bundle format (spec v1)
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
bundle.zip
|
|
95
|
+
├── manifest.json # spec, version, counts, source
|
|
96
|
+
├── cookies.json # Playwright cookie list
|
|
97
|
+
├── origins/
|
|
98
|
+
│ └── https%3A%2F%2Fexample.com.json # localStorage + sessionStorage
|
|
99
|
+
└── indexeddb/
|
|
100
|
+
└── metadata.json # IDB database names/versions (metadata only)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
IndexedDB **metadata** is included for audit/diff; full IDB binary export is out of scope.
|
|
104
|
+
|
|
105
|
+
`manifest.json` example:
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"spec": "session-bundle-v1",
|
|
110
|
+
"version": 1,
|
|
111
|
+
"source": "playwright-storage-state",
|
|
112
|
+
"cookie_count": 12,
|
|
113
|
+
"origin_count": 3,
|
|
114
|
+
"indexeddb_count": 0
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## CLI
|
|
119
|
+
|
|
120
|
+
| Command | Description |
|
|
121
|
+
|---------|-------------|
|
|
122
|
+
| `session-bundle export --playwright-context FILE -o OUT.zip` | Create bundle from Playwright state |
|
|
123
|
+
| `session-bundle import FILE --dry-run` | Validate bundle without writing (default) |
|
|
124
|
+
| `session-bundle diff A.zip B.zip` | Cookie domain + storage/IDB diff |
|
|
125
|
+
| `session-bundle mlx-pull --profile-id UUID -o bundle.zip` | Export MLX profile cookies → bundle (`[mlx]`) |
|
|
126
|
+
| `session-bundle mlx-push --profile-id UUID --bundle FILE` | Import bundle cookies to MLX (`[mlx]`) |
|
|
127
|
+
| `session-bundle mlx-push ... --dry-run` | Validate bundle; no MLX API calls |
|
|
128
|
+
|
|
129
|
+
## API
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from session_bundle_kit import export_playwright_context, validate_bundle, diff_bundles
|
|
133
|
+
|
|
134
|
+
export_playwright_context("state.json", "bundle.zip")
|
|
135
|
+
report = validate_bundle("bundle.zip")
|
|
136
|
+
diff = diff_bundles("old.zip", "new.zip")
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Restore to Playwright:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from session_bundle_kit.bundle import SessionBundle
|
|
143
|
+
|
|
144
|
+
bundle = SessionBundle.read_zip("bundle.zip")
|
|
145
|
+
context = await browser.new_context(storage_state=bundle.to_playwright_storage_state())
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## MLX pull / push (`[mlx]` extra)
|
|
149
|
+
|
|
150
|
+
Round-trip **cookies** between MLX profiles and neutral **session-bundle-v1** zips. Storage/IDB in bundles from Playwright export are manifest-only on push; MLX Launcher APIs move cookies only.
|
|
151
|
+
|
|
152
|
+
**Pull** (profile → zip):
|
|
153
|
+
|
|
154
|
+
1. `GET api.multilogin.com/profile/unlock?profile_ids=UUID`
|
|
155
|
+
2. `POST launcher.mlx.yt:45001/api/v1/cookies/export`
|
|
156
|
+
|
|
157
|
+
**Push** (zip → profile):
|
|
158
|
+
|
|
159
|
+
1. Validate bundle (`--dry-run` stops here)
|
|
160
|
+
2. `GET api.multilogin.com/profile/unlock?profile_ids=UUID`
|
|
161
|
+
3. `POST launcher.mlx.yt:45001/api/v1/cookies/import`
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
export MLX_TOKEN="..."
|
|
165
|
+
|
|
166
|
+
# Export MLX profile session to portable bundle
|
|
167
|
+
session-bundle mlx-pull --profile-id PROFILE_UUID -o bundle.zip
|
|
168
|
+
|
|
169
|
+
# Validate before import
|
|
170
|
+
session-bundle mlx-push --profile-id PROFILE_UUID --bundle bundle.zip --dry-run
|
|
171
|
+
|
|
172
|
+
# Import cookies into target profile
|
|
173
|
+
session-bundle mlx-push --profile-id PROFILE_UUID --bundle bundle.zip
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Peer pattern for Launcher lifecycle: see `cdp-connect-kit` MLX integration docs.
|
|
177
|
+
|
|
178
|
+
## When login sessions break after profile switch (playbook)
|
|
179
|
+
|
|
180
|
+
Sites re-challenge when cookies land on a **new fingerprint or IP**. Use bundles to move **stored** credentials deliberately — not as a bypass.
|
|
181
|
+
|
|
182
|
+
| Symptom | Likely cause | Next step |
|
|
183
|
+
|---------|--------------|-----------|
|
|
184
|
+
| Import ok, still logged out | Domain/subdomain mismatch, expired cookies | `session-bundle diff` old vs new; fix with [cookie-jar-bridge](https://pypi.org/project/cookie-jar-bridge/) |
|
|
185
|
+
| Step-up / CAPTCHA after restore | IP or fingerprint drift | Match proxy lane; probe with [playwright-cdp-probe](https://pypi.org/project/playwright-cdp-probe/) |
|
|
186
|
+
| MLX push succeeds, site fails | Cookies only — no `localStorage` on target | Restore storage via Playwright `storage_state` on same profile |
|
|
187
|
+
| Migration metadata only | `antidetect-importer` left `_cookies` sidecar | `mlx-pull` source → edit → `mlx-push --dry-run` → push |
|
|
188
|
+
|
|
189
|
+
**Session migration pipeline:**
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
session-bundle export --playwright-context state.json -o bundle.zip
|
|
193
|
+
session-bundle import bundle.zip --dry-run
|
|
194
|
+
session-bundle mlx-push --profile-id TARGET_UUID --bundle bundle.zip --dry-run
|
|
195
|
+
export MLX_TOKEN=...
|
|
196
|
+
session-bundle mlx-push --profile-id TARGET_UUID --bundle bundle.zip
|
|
197
|
+
cdp-probe mlx --profile-id TARGET_UUID --url https://your-app.example # verify session + exposure
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Treat bundle zips as **secrets** — same sensitivity as password files.
|
|
201
|
+
|
|
202
|
+
## When Chrome `user-data-dir` fails in 2026
|
|
203
|
+
|
|
204
|
+
Starting with **Chrome 136** (March 2025), Google changed how remote debugging works on the default profile. Per the [Chrome Developers blog](https://developer.chrome.com/blog/remote-debugging-port):
|
|
205
|
+
|
|
206
|
+
- `--remote-debugging-port` and `--remote-debugging-pipe` are **ignored** when targeting Chrome's **default** user data directory.
|
|
207
|
+
- You must pass a **custom** `--user-data-dir` pointing to a non-default profile path for CDP to bind.
|
|
208
|
+
- The change targets cookie theft via remote debugging after [App-Bound Encryption](https://developer.chrome.com/blog/app-bound-encryption); Chrome for Testing still supports the older automation workflow.
|
|
209
|
+
|
|
210
|
+
**Practical impact:** copying your daily `Default` profile and launching with `--remote-debugging-port` no longer works on stock Chrome 136+. Session-bundle-kit sidesteps this by exporting **Playwright `storage_state.json`** (cookies + origin storage) into portable zips — restore via `new_context(storage_state=...)` on antidetect or isolated profiles instead of attaching CDP to your real Chrome profile.
|
|
211
|
+
|
|
212
|
+
Enterprise admins can still control remote debugging via the [Allow remote debugging](https://support.google.com/chrome/a/answer/2657289) Chrome policy on managed devices.
|
|
213
|
+
|
|
214
|
+
## Limitations
|
|
215
|
+
|
|
216
|
+
- **No live IDB export** — IndexedDB section is metadata manifest only.
|
|
217
|
+
- **sessionStorage** preserved in bundle format but Playwright native export may omit it; include in custom state JSON if needed.
|
|
218
|
+
- **mlx-push** imports cookies only; storage must be restored via Playwright or profile sync separately.
|
|
219
|
+
- **Secrets** — bundles contain session secrets; treat zip files as credentials. Import warns on likely plaintext passwords in web storage.
|
|
220
|
+
|
|
221
|
+
## Production
|
|
222
|
+
|
|
223
|
+
Export sessions to portable zips, validate with `mlx-push --dry-run`, then import cookies into MLX profiles after `antidetect-importer` migration or failed login on a new profile.
|
|
224
|
+
|
|
225
|
+
**Partner note (affiliate):** When restoring sessions into **isolated Multilogin X** profiles (not shared Chrome `user-data-dir`), Launcher cookie import pairs with profile-level proxy and fingerprint. Eligible new browser purchases may accept code **`SAAS50`** at [multilogin.com](https://multilogin.com/?ref=SAAS50) — verify terms before checkout. Export/diff/validate work without MLX. Full disclosure: [docs/AFFILIATE.md](docs/AFFILIATE.md). Coupon details only via `session-bundle --show-deal` (never printed by default).
|
|
226
|
+
|
|
227
|
+
**FAQ:** [docs/FAQ.md](docs/FAQ.md) — Playwright storage_state export, Chrome 136 CDP, MLX cookie import.
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
## Related tools (on PyPI)
|
|
231
|
+
|
|
232
|
+
| Package | CLI | Role |
|
|
233
|
+
|---------|-----|------|
|
|
234
|
+
| [playwright-cdp-probe](https://pypi.org/project/playwright-cdp-probe/) | `cdp-probe` | CDP / WebDriver exposure score |
|
|
235
|
+
| [cookie-jar-bridge](https://pypi.org/project/cookie-jar-bridge/) | `cookie-bridge` | Netscape ↔ Playwright cookies |
|
|
236
|
+
| [proxy-lane-checker](https://pypi.org/project/proxy-lane-checker/) | `proxy-lane` | Proxy TCP / HTTP / geo / DNSBL |
|
|
237
|
+
| [fingerprint-coherence](https://pypi.org/project/fingerprint-coherence/) | `fp-coherence` | UA / screen / timezone lint |
|
|
238
|
+
|
|
239
|
+
**Toolkit pipeline:** `proxy-lane check` → `fp-coherence audit` → automate → `cdp-probe run` → `cookie-bridge validate`
|
|
240
|
+
|
|
241
|
+
## License
|
|
242
|
+
|
|
243
|
+
MIT
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
**Production antidetect:** [Multilogin X](https://multilogin.com/?ref=SAAS50) · Code `SAAS50` (-50% browser) · `MIN50` (-50% cloud phone)
|
|
248
|
+
More scripts: [@Multilogin_Scripts_Bot](https://t.me/Multilogin_Scripts_Bot) · [Coupons](https://anti-detect.github.io/)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
session_bundle_kit/__init__.py,sha256=7Ohl5YiaV7ZKvYg-Fn8eD7jpvp73B8jfs5MVmNRdJ2c,652
|
|
2
|
+
session_bundle_kit/bundle.py,sha256=AQMO-YDvsIiN0D7zWZsueK-rTIB65QpAy--zvh1e4iM,4489
|
|
3
|
+
session_bundle_kit/cli.py,sha256=JkZxU3JhTeG5MnDmvgmRmjMTC8obaE7KX6CXAk_bFUg,6225
|
|
4
|
+
session_bundle_kit/deal.py,sha256=H4CiOBsqh4U88YbtOPJctPMa4l7POqBCLvFLByDEHKQ,830
|
|
5
|
+
session_bundle_kit/diff.py,sha256=GuSUoOXX_zMUdCjTHhqlMFybMC1HCU2r5tP7ciRioi0,4975
|
|
6
|
+
session_bundle_kit/export.py,sha256=oEa44APpZtc-Vm3gIHxE9eXSzwBrDTQjMWlj8G9qjP8,994
|
|
7
|
+
session_bundle_kit/import_bundle.py,sha256=93BB2zU0iomPUwphVAzOblp53nTbXHtEm4zp7ookMCs,3972
|
|
8
|
+
session_bundle_kit/mlx.py,sha256=tC63fRqZyFwaR1JeubPncRzLUMPPXlbBHrR_lOkhBWI,9462
|
|
9
|
+
session_bundle_kit/models.py,sha256=8AfKOx_YHU6AcrpzNeTsAck9Rp5dp46kbnX_tO8BWHs,3506
|
|
10
|
+
session_bundle_kit/security.py,sha256=_6R2f1WTwbRKFQLdhH__bIj--Gt5qOf-3rtXCZ4qoeU,1337
|
|
11
|
+
session_bundle_kit-0.3.1.dist-info/METADATA,sha256=rCGL3-Qln7zSBiVMha_CPkysO8l2wBd3ch-b35WtCrA,11364
|
|
12
|
+
session_bundle_kit-0.3.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
13
|
+
session_bundle_kit-0.3.1.dist-info/entry_points.txt,sha256=sX9LBSfqC__eOyTBpJbdIhxxI5E_HnfbX3JN_sS6TPQ,63
|
|
14
|
+
session_bundle_kit-0.3.1.dist-info/licenses/LICENSE,sha256=vVN3xzHbJKXlwLBCl91WK95OLPEtmqu3AlnSpz3UJx0,1088
|
|
15
|
+
session_bundle_kit-0.3.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 session-bundle-kit contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|