flowcvcli 0.5.2__tar.gz → 0.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {flowcvcli-0.5.2/flowcvcli.egg-info → flowcvcli-0.6.0}/PKG-INFO +3 -2
- {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli/__init__.py +1 -1
- {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli/client.py +112 -33
- {flowcvcli-0.5.2 → flowcvcli-0.6.0/flowcvcli.egg-info}/PKG-INFO +3 -2
- {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli.egg-info/SOURCES.txt +1 -0
- flowcvcli-0.6.0/flowcvcli.egg-info/requires.txt +1 -0
- {flowcvcli-0.5.2 → flowcvcli-0.6.0}/pyproject.toml +5 -2
- {flowcvcli-0.5.2 → flowcvcli-0.6.0}/.env.example +0 -0
- {flowcvcli-0.5.2 → flowcvcli-0.6.0}/LICENSE +0 -0
- {flowcvcli-0.5.2 → flowcvcli-0.6.0}/MANIFEST.in +0 -0
- {flowcvcli-0.5.2 → flowcvcli-0.6.0}/README.md +0 -0
- {flowcvcli-0.5.2 → flowcvcli-0.6.0}/docs/API.md +0 -0
- {flowcvcli-0.5.2 → flowcvcli-0.6.0}/docs/RENDERING.md +0 -0
- {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli/__main__.py +0 -0
- {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli/api.py +0 -0
- {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli/cli.py +0 -0
- {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli/config.py +0 -0
- {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli/content.py +0 -0
- {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli/customization.py +0 -0
- {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli/markup.py +0 -0
- {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli/personal.py +0 -0
- {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli/photo.py +0 -0
- {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli/resume.py +0 -0
- {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli.egg-info/dependency_links.txt +0 -0
- {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli.egg-info/entry_points.txt +0 -0
- {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli.egg-info/top_level.txt +0 -0
- {flowcvcli-0.5.2 → flowcvcli-0.6.0}/setup.cfg +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flowcvcli
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Control a FlowCV resume from the command line or Python — content, design, templates, photo, publish and PDF export — via FlowCV's private JSON API.
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: Control a FlowCV resume from the command line or Python — content, design, templates, photo, publish and PDF export — via FlowCV's private JSON API.
|
|
5
5
|
Author: dannyota
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/dannyota/flowcvcli
|
|
@@ -26,6 +26,7 @@ Classifier: Topic :: Utilities
|
|
|
26
26
|
Requires-Python: >=3.8
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
License-File: LICENSE
|
|
29
|
+
Requires-Dist: curl_cffi>=0.7
|
|
29
30
|
Dynamic: license-file
|
|
30
31
|
|
|
31
32
|
# flowcvcli
|
|
@@ -35,6 +35,76 @@ COOKIE_DOMAIN = "app.flowcv.com"
|
|
|
35
35
|
UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
36
36
|
"Chrome/149.0.0.0 Safari/537.36")
|
|
37
37
|
|
|
38
|
+
# The web app always carries an `appVersion` build-hash cookie and an `i18n`
|
|
39
|
+
# locale cookie, and the API expects them on requests, so we send them on login
|
|
40
|
+
# and persist them with the session. (They are NOT what fixes the login 429 —
|
|
41
|
+
# that's the TLS fingerprint; see `_impersonated_login`.)
|
|
42
|
+
#
|
|
43
|
+
# `appVersion` is a build hash that changes on every FlowCV deploy, so we DON'T
|
|
44
|
+
# hard-code it: we GET the app root and capture the `Set-Cookie: appVersion=...`
|
|
45
|
+
# the server emits (exactly how the browser gets it). The constant below is only
|
|
46
|
+
# a last-resort fallback if that fetch fails; FLOWCV_APP_VERSION overrides both.
|
|
47
|
+
APP_VERSION_FALLBACK = "a66cb813d07308dcd4b0332278e3f9a2fcef0bb5"
|
|
48
|
+
I18N_COOKIE = os.environ.get("FLOWCV_I18N", "en|vn")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def fetch_app_version():
|
|
52
|
+
"""GET the app root and return the current `appVersion` build hash.
|
|
53
|
+
|
|
54
|
+
The browser obtains `appVersion` from the Set-Cookie on the first page load;
|
|
55
|
+
we mirror that so the value is always current rather than pinned to a stale
|
|
56
|
+
build hash. Returns the captured value, or None if the fetch failed. This is
|
|
57
|
+
a plain GET (not the login POST), so it isn't subject to the WAF throttling.
|
|
58
|
+
"""
|
|
59
|
+
jar = http.cookiejar.CookieJar()
|
|
60
|
+
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
|
61
|
+
try:
|
|
62
|
+
opener.open(urllib.request.Request(ORIGIN + "/", headers={"user-agent": UA}),
|
|
63
|
+
timeout=30).read()
|
|
64
|
+
except (urllib.error.HTTPError, urllib.error.URLError):
|
|
65
|
+
return None
|
|
66
|
+
for c in jar:
|
|
67
|
+
if c.name == "appVersion":
|
|
68
|
+
return c.value
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def resolve_app_version():
|
|
73
|
+
"""The appVersion to send on login: env override > live fetch > pinned fallback."""
|
|
74
|
+
return (os.environ.get("FLOWCV_APP_VERSION")
|
|
75
|
+
or fetch_app_version()
|
|
76
|
+
or APP_VERSION_FALLBACK)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _impersonated_login(email, password, send_cookies):
|
|
80
|
+
"""POST /auth/login with a Chrome TLS fingerprint; return (status, body, cookies).
|
|
81
|
+
|
|
82
|
+
FlowCV's edge fingerprints the TLS ClientHello (JA3) and throttles Python's
|
|
83
|
+
`ssl` stack on the login endpoint — every stdlib client (urllib, http.client)
|
|
84
|
+
gets an application-level HTTP 429, while curl and real browsers from the SAME
|
|
85
|
+
ip/headers/cookies/moment are accepted. Verified end to end: byte-identical
|
|
86
|
+
headers+ordering via http.client 429s, and Python login even WITH a valid
|
|
87
|
+
session cookie 429s, while a browser fetch with that same session 200s. So the
|
|
88
|
+
fix is to present a browser TLS fingerprint, which `curl_cffi` does in pure
|
|
89
|
+
Python (libcurl-impersonate) — no subprocess, no system curl.
|
|
90
|
+
|
|
91
|
+
`send_cookies` is the {name: value} cookie dict to send (appVersion/i18n).
|
|
92
|
+
Returns (status, response_text, {name: value} of cookies the server set).
|
|
93
|
+
Raises ImportError if curl_cffi is not installed (the caller surfaces it).
|
|
94
|
+
"""
|
|
95
|
+
from curl_cffi import requests as _cffi # lazy: only login needs it
|
|
96
|
+
|
|
97
|
+
boundary, body = _multipart({"email": email, "password": password,
|
|
98
|
+
"resumeData": "undefined", "letterData": "undefined",
|
|
99
|
+
"resumeImg": "", "letterImg": ""})
|
|
100
|
+
r = _cffi.post(
|
|
101
|
+
ORIGIN + "/api/auth/login",
|
|
102
|
+
headers={"accept": "application/json, text/plain, */*",
|
|
103
|
+
"content-type": f"multipart/form-data; boundary={boundary}",
|
|
104
|
+
"origin": ORIGIN},
|
|
105
|
+
cookies=send_cookies, data=body, impersonate="chrome", timeout=60)
|
|
106
|
+
return r.status_code, r.text, dict(r.cookies)
|
|
107
|
+
|
|
38
108
|
|
|
39
109
|
def now_iso():
|
|
40
110
|
n = datetime.datetime.now(datetime.timezone.utc)
|
|
@@ -84,45 +154,54 @@ def _rate_limit_msg(method, path):
|
|
|
84
154
|
|
|
85
155
|
|
|
86
156
|
def login(email, password, jar=None):
|
|
87
|
-
"""
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
157
|
+
"""Authenticate and load the full session cookie set into a cookie jar.
|
|
158
|
+
|
|
159
|
+
FlowCV's edge fingerprints the TLS ClientHello (JA3) and throttles Python's
|
|
160
|
+
`ssl` on the login endpoint — every stdlib client (urllib, http.client) gets
|
|
161
|
+
an application-level HTTP 429, while curl and real browsers from the SAME
|
|
162
|
+
ip/headers/cookies/moment are accepted. Verified empirically: byte-identical
|
|
163
|
+
headers+ordering via http.client 429s, Python login even WITH a valid session
|
|
164
|
+
cookie 429s, and a browser fetch with that same session 200s. So we send the
|
|
165
|
+
login POST with a Chrome TLS fingerprint via curl_cffi (see
|
|
166
|
+
`_impersonated_login`) and capture the session cookies. Every OTHER call stays
|
|
167
|
+
on urllib — only the login endpoint is fingerprint-gated.
|
|
168
|
+
|
|
169
|
+
Returns the jar (creating a fresh one if not supplied), populated with the
|
|
170
|
+
session cookie plus the `appVersion`/`i18n` cookies the API expects on every
|
|
171
|
+
later request. Raises SystemExit on a 429, a missing curl_cffi, or a failed
|
|
172
|
+
login.
|
|
93
173
|
"""
|
|
94
174
|
if jar is None:
|
|
95
175
|
jar = http.cookiejar.CookieJar()
|
|
96
|
-
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
|
97
|
-
base = {"user-agent": UA, "origin": ORIGIN, "accept": "application/json, text/plain, */*"}
|
|
98
176
|
|
|
99
|
-
#
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
opener.open(urllib.request.Request(f"{API}/auth/init_user", headers=base), timeout=30).read()
|
|
104
|
-
except urllib.error.HTTPError:
|
|
105
|
-
pass
|
|
106
|
-
except urllib.error.URLError as e:
|
|
107
|
-
raise SystemExit(f"login (init_user) -> network error: {e.reason}")
|
|
177
|
+
# Browser-style cookies the API expects: live appVersion build-hash + locale.
|
|
178
|
+
appver = resolve_app_version()
|
|
179
|
+
jar.set_cookie(_make_cookie("i18n", I18N_COOKIE))
|
|
180
|
+
jar.set_cookie(_make_cookie("appVersion", appver))
|
|
108
181
|
|
|
109
|
-
boundary, body = _multipart({"email": email, "password": password,
|
|
110
|
-
"resumeData": "undefined", "letterData": "undefined",
|
|
111
|
-
"resumeImg": "", "letterImg": ""})
|
|
112
|
-
h = dict(base, **{"content-type": f"multipart/form-data; boundary={boundary}"})
|
|
113
182
|
try:
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
except
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
raise SystemExit(f"login failed
|
|
183
|
+
status, info, set_cookies = _impersonated_login(
|
|
184
|
+
email, password, {"i18n": I18N_COOKIE, "appVersion": appver})
|
|
185
|
+
except ImportError:
|
|
186
|
+
raise SystemExit(
|
|
187
|
+
"login needs the 'curl_cffi' package: FlowCV throttles Python's TLS "
|
|
188
|
+
"fingerprint on the login endpoint, and curl_cffi presents a browser "
|
|
189
|
+
"one. Install it (`pip install curl_cffi`), or set FLOWCV_COOKIE to a "
|
|
190
|
+
"session cookie captured from a logged-in browser.")
|
|
191
|
+
if status == 429:
|
|
192
|
+
raise SystemExit(_rate_limit_msg("POST", "auth/login"))
|
|
193
|
+
if status != 200:
|
|
194
|
+
raise SystemExit(f"login failed: HTTP {status} {info[:200]!r}")
|
|
195
|
+
try:
|
|
196
|
+
data = json.loads(info)
|
|
197
|
+
except ValueError:
|
|
198
|
+
data = {}
|
|
199
|
+
if data and not data.get("success"):
|
|
200
|
+
raise SystemExit(f"login failed (code {data.get('code')}): "
|
|
201
|
+
f"{data.get('error') or 'check email/password'}")
|
|
202
|
+
|
|
203
|
+
for name, value in set_cookies.items():
|
|
204
|
+
jar.set_cookie(_make_cookie(name, value))
|
|
126
205
|
if not any(c.name == "flowcvsidapp" for c in jar):
|
|
127
206
|
raise SystemExit("login succeeded but no session cookie was set")
|
|
128
207
|
return jar
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flowcvcli
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Control a FlowCV resume from the command line or Python — content, design, templates, photo, publish and PDF export — via FlowCV's private JSON API.
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: Control a FlowCV resume from the command line or Python — content, design, templates, photo, publish and PDF export — via FlowCV's private JSON API.
|
|
5
5
|
Author: dannyota
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/dannyota/flowcvcli
|
|
@@ -26,6 +26,7 @@ Classifier: Topic :: Utilities
|
|
|
26
26
|
Requires-Python: >=3.8
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
License-File: LICENSE
|
|
29
|
+
Requires-Dist: curl_cffi>=0.7
|
|
29
30
|
Dynamic: license-file
|
|
30
31
|
|
|
31
32
|
# flowcvcli
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
curl_cffi>=0.7
|
|
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "flowcvcli"
|
|
7
7
|
dynamic = ["version"]
|
|
8
|
-
description = "Control a FlowCV resume from the command line or Python — content, design, templates, photo, publish and PDF export — via FlowCV's private JSON API.
|
|
8
|
+
description = "Control a FlowCV resume from the command line or Python — content, design, templates, photo, publish and PDF export — via FlowCV's private JSON API."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
11
11
|
license = "MIT"
|
|
@@ -28,7 +28,10 @@ classifiers = [
|
|
|
28
28
|
"Topic :: Office/Business",
|
|
29
29
|
"Topic :: Utilities",
|
|
30
30
|
]
|
|
31
|
-
|
|
31
|
+
# curl_cffi presents a browser TLS (JA3) fingerprint for the login POST only —
|
|
32
|
+
# FlowCV's edge throttles Python's stdlib `ssl` fingerprint on /auth/login with
|
|
33
|
+
# HTTP 429 while accepting browsers/curl. All other calls use the stdlib.
|
|
34
|
+
dependencies = ["curl_cffi>=0.7"]
|
|
32
35
|
|
|
33
36
|
[project.scripts]
|
|
34
37
|
flowcv = "flowcvcli.cli:main"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|