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.
Files changed (27) hide show
  1. {flowcvcli-0.5.2/flowcvcli.egg-info → flowcvcli-0.6.0}/PKG-INFO +3 -2
  2. {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli/__init__.py +1 -1
  3. {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli/client.py +112 -33
  4. {flowcvcli-0.5.2 → flowcvcli-0.6.0/flowcvcli.egg-info}/PKG-INFO +3 -2
  5. {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli.egg-info/SOURCES.txt +1 -0
  6. flowcvcli-0.6.0/flowcvcli.egg-info/requires.txt +1 -0
  7. {flowcvcli-0.5.2 → flowcvcli-0.6.0}/pyproject.toml +5 -2
  8. {flowcvcli-0.5.2 → flowcvcli-0.6.0}/.env.example +0 -0
  9. {flowcvcli-0.5.2 → flowcvcli-0.6.0}/LICENSE +0 -0
  10. {flowcvcli-0.5.2 → flowcvcli-0.6.0}/MANIFEST.in +0 -0
  11. {flowcvcli-0.5.2 → flowcvcli-0.6.0}/README.md +0 -0
  12. {flowcvcli-0.5.2 → flowcvcli-0.6.0}/docs/API.md +0 -0
  13. {flowcvcli-0.5.2 → flowcvcli-0.6.0}/docs/RENDERING.md +0 -0
  14. {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli/__main__.py +0 -0
  15. {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli/api.py +0 -0
  16. {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli/cli.py +0 -0
  17. {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli/config.py +0 -0
  18. {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli/content.py +0 -0
  19. {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli/customization.py +0 -0
  20. {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli/markup.py +0 -0
  21. {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli/personal.py +0 -0
  22. {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli/photo.py +0 -0
  23. {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli/resume.py +0 -0
  24. {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli.egg-info/dependency_links.txt +0 -0
  25. {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli.egg-info/entry_points.txt +0 -0
  26. {flowcvcli-0.5.2 → flowcvcli-0.6.0}/flowcvcli.egg-info/top_level.txt +0 -0
  27. {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.5.2
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. Zero dependencies.
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
@@ -5,4 +5,4 @@ from .content import SECTION_META, label_of
5
5
  from .markup import html_to_text, md_to_html
6
6
 
7
7
  __all__ = ["FlowCV", "Config", "SECTION_META", "label_of", "md_to_html", "html_to_text"]
8
- __version__ = "0.5.2"
8
+ __version__ = "0.6.0"
@@ -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
- """POST /auth/login (multipart email+password) into a cookie jar.
88
-
89
- Seeds an anonymous session (init_user) then logs in on the *same* jar, so the
90
- jar ends up holding the full authenticated cookie set. Returns the jar
91
- (creating a fresh one if not supplied). Raises SystemExit with a clear message
92
- on a 429 rate-limit or a failed login.
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
- # Seed an anonymous session like the web app does but login also works
100
- # standalone (the browser's curl skips this), so a throttled/failed init_user
101
- # must NOT abort a login that would otherwise succeed. Best-effort only.
102
- try:
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
- resp = opener.open(
115
- urllib.request.Request(f"{API}/auth/login", data=body, headers=h, method="POST"), timeout=30)
116
- except urllib.error.HTTPError as e:
117
- if e.code == 429:
118
- raise SystemExit(_rate_limit_msg("POST", "auth/login"))
119
- raise SystemExit(f"login failed: HTTP {e.code} {e.read()[:200]!r}")
120
- except urllib.error.URLError as e:
121
- raise SystemExit(f"login -> network error: {e.reason}")
122
- data = json.loads(resp.read().decode())
123
- if not data.get("success"):
124
- # whitelist error/code only — the raw envelope can echo the account email
125
- raise SystemExit(f"login failed (code {data.get('code')}): {data.get('error') or 'check email/password'}")
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.5.2
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. Zero dependencies.
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
@@ -21,4 +21,5 @@ flowcvcli.egg-info/PKG-INFO
21
21
  flowcvcli.egg-info/SOURCES.txt
22
22
  flowcvcli.egg-info/dependency_links.txt
23
23
  flowcvcli.egg-info/entry_points.txt
24
+ flowcvcli.egg-info/requires.txt
24
25
  flowcvcli.egg-info/top_level.txt
@@ -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. Zero dependencies."
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
- dependencies = []
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