flowcvcli 0.2.0__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.2.0/flowcvcli.egg-info → flowcvcli-0.6.0}/PKG-INFO +24 -8
  2. {flowcvcli-0.2.0 → flowcvcli-0.6.0}/README.md +21 -6
  3. {flowcvcli-0.2.0 → flowcvcli-0.6.0}/flowcvcli/__init__.py +1 -1
  4. {flowcvcli-0.2.0 → flowcvcli-0.6.0}/flowcvcli/cli.py +82 -12
  5. {flowcvcli-0.2.0 → flowcvcli-0.6.0}/flowcvcli/client.py +112 -33
  6. {flowcvcli-0.2.0 → flowcvcli-0.6.0}/flowcvcli/content.py +50 -10
  7. {flowcvcli-0.2.0 → flowcvcli-0.6.0}/flowcvcli/markup.py +11 -2
  8. {flowcvcli-0.2.0 → flowcvcli-0.6.0}/flowcvcli/resume.py +21 -8
  9. {flowcvcli-0.2.0 → flowcvcli-0.6.0/flowcvcli.egg-info}/PKG-INFO +24 -8
  10. {flowcvcli-0.2.0 → flowcvcli-0.6.0}/flowcvcli.egg-info/SOURCES.txt +1 -0
  11. flowcvcli-0.6.0/flowcvcli.egg-info/requires.txt +1 -0
  12. {flowcvcli-0.2.0 → flowcvcli-0.6.0}/pyproject.toml +5 -2
  13. {flowcvcli-0.2.0 → flowcvcli-0.6.0}/.env.example +0 -0
  14. {flowcvcli-0.2.0 → flowcvcli-0.6.0}/LICENSE +0 -0
  15. {flowcvcli-0.2.0 → flowcvcli-0.6.0}/MANIFEST.in +0 -0
  16. {flowcvcli-0.2.0 → flowcvcli-0.6.0}/docs/API.md +0 -0
  17. {flowcvcli-0.2.0 → flowcvcli-0.6.0}/docs/RENDERING.md +0 -0
  18. {flowcvcli-0.2.0 → flowcvcli-0.6.0}/flowcvcli/__main__.py +0 -0
  19. {flowcvcli-0.2.0 → flowcvcli-0.6.0}/flowcvcli/api.py +0 -0
  20. {flowcvcli-0.2.0 → flowcvcli-0.6.0}/flowcvcli/config.py +0 -0
  21. {flowcvcli-0.2.0 → flowcvcli-0.6.0}/flowcvcli/customization.py +0 -0
  22. {flowcvcli-0.2.0 → flowcvcli-0.6.0}/flowcvcli/personal.py +0 -0
  23. {flowcvcli-0.2.0 → flowcvcli-0.6.0}/flowcvcli/photo.py +0 -0
  24. {flowcvcli-0.2.0 → flowcvcli-0.6.0}/flowcvcli.egg-info/dependency_links.txt +0 -0
  25. {flowcvcli-0.2.0 → flowcvcli-0.6.0}/flowcvcli.egg-info/entry_points.txt +0 -0
  26. {flowcvcli-0.2.0 → flowcvcli-0.6.0}/flowcvcli.egg-info/top_level.txt +0 -0
  27. {flowcvcli-0.2.0 → 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.2.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. 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,17 +26,18 @@ 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
32
33
 
33
34
  [![PyPI](https://img.shields.io/pypi/v/flowcvcli.svg)](https://pypi.org/project/flowcvcli/)
34
- [![Python](https://img.shields.io/pypi/pyversions/flowcvcli.svg)](https://pypi.org/project/flowcvcli/)
35
+ [![Python](https://img.shields.io/badge/python-3.8%2B-blue.svg)](https://pypi.org/project/flowcvcli/)
35
36
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
36
37
 
37
38
  Control a [FlowCV](https://flowcv.com) resume from the **command line** or from
38
39
  **Python** — content, header & links, **customization**, **templates**,
39
- **avatar**, reorder/hide, multi-resume management, publish, and **PDF export**.
40
+ **avatar**, reorder/hide, multi-resume management, **backup/restore**, publish, and **PDF export**.
40
41
  It drives FlowCV's private JSON API (the same calls the web app makes), so it
41
42
  works for any FlowCV resume with your own session. **Zero dependencies** (Python
42
43
  standard library only), so it's easy to drop into scripts and LLM agents.
@@ -96,10 +97,13 @@ flowcv duplicate ["Copy title"] # full copy of the current resume
96
97
  flowcv rename "New Title" # rename the current resume
97
98
  flowcv delete-resume --yes # permanent (refuses without --yes)
98
99
 
99
- # content (markdown mini-format below); `add` creates the section if needed
100
+ # content (markdown mini-format below); `add` creates the section if needed.
101
+ # --set aliases (title/company/link) resolve per section (work->jobTitle, publication->title…)
100
102
  flowcv add work --set title="Engineer" --set company="Acme" \
101
103
  --set start=01/2022 --set end=Present --text $'- Did a measurable thing.'
102
- flowcv desc work <id> --file role.md
104
+ flowcv add custom2 --section-name "Open Source" --icon code --text "…" # heading+icon at creation
105
+ flowcv desc work <id> --file role.md # rich text; --field is optional (auto: profile=text, skill=infoHtml)
106
+ flowcv date publication <id> --year 2018 # structured date (year-only by default)
103
107
  flowcv field work <id> employer --text "Acme Corp"
104
108
  flowcv rm work <id>
105
109
 
@@ -109,7 +113,7 @@ flowcv hide work <id> ; flowcv show-entry work <id>
109
113
  flowcv rename-section skill "Core Skills"
110
114
  flowcv section-icon skill head-side-brain
111
115
  flowcv rm-section custom1 --yes # delete a section + its entries
112
- flowcv reorder-sections profile work skill education # one-column order
116
+ flowcv reorder-sections profile work skill education # set one-column order (no args = print current)
113
117
 
114
118
  # header details & links (links are social entries: orcid, googlescholar, github…)
115
119
  flowcv pd jobTitle --text "Security Leader"
@@ -131,6 +135,10 @@ flowcv download -o resume.pdf # the rendered PDF
131
135
  flowcv download --token <webToken> -o out.pdf # any PUBLIC resume by its share token (no auth)
132
136
  flowcv share | publish | unpublish
133
137
 
138
+ # backup / restore
139
+ flowcv export -o backup.json # full resume snapshot (JSON) — keep one before big edits
140
+ flowcv import backup.json # restore the snapshot into a NEW resume (non-destructive)
141
+
134
142
  flowcv login # refresh the cached session
135
143
  flowcv md2html --file role.md # preview HTML (offline)
136
144
  ```
@@ -159,6 +167,12 @@ fc.rename_section("skill", "Core Skills"); fc.delete_section("custom1")
159
167
  fc.hide_entry("work", "id", hidden=True)
160
168
  new_id = fc.create_resume("Second Resume") # or fc.duplicate_resume()
161
169
  fc.rename_resume("New Title"); fc.delete_resume() # delete is permanent
170
+ fc.set_date("publication", "id", year=2018) # structured date (year-only)
171
+
172
+ # backup / restore
173
+ import json
174
+ json.dump(fc.export_resume(), open("backup.json", "w")) # full snapshot
175
+ new_id = fc.import_resume(json.load(open("backup.json"))) # restore into a NEW resume
162
176
  ```
163
177
 
164
178
  ### Build → render → check → improve
@@ -173,9 +187,11 @@ feedback loop for building a resume from raw info.
173
187
  |---|---|
174
188
  | blank line | block separator |
175
189
  | `## Heading` / `**Whole line bold**` | bold subheader |
190
+ | `***Whole line***` | bold + italic subheader |
176
191
  | `- item` | bullet (consecutive = one list) |
177
192
  | anything else | justified paragraph |
178
- | `**bold**` inline | `<strong>bold</strong>` |
193
+ | `**bold**` / `***bold-italic***` inline | `<strong>` / `<strong><em>…</em></strong>` |
194
+ | `[text](url)` inline | link |
179
195
 
180
196
  ## How it works
181
197
 
@@ -1,12 +1,12 @@
1
1
  # flowcvcli
2
2
 
3
3
  [![PyPI](https://img.shields.io/pypi/v/flowcvcli.svg)](https://pypi.org/project/flowcvcli/)
4
- [![Python](https://img.shields.io/pypi/pyversions/flowcvcli.svg)](https://pypi.org/project/flowcvcli/)
4
+ [![Python](https://img.shields.io/badge/python-3.8%2B-blue.svg)](https://pypi.org/project/flowcvcli/)
5
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
6
 
7
7
  Control a [FlowCV](https://flowcv.com) resume from the **command line** or from
8
8
  **Python** — content, header & links, **customization**, **templates**,
9
- **avatar**, reorder/hide, multi-resume management, publish, and **PDF export**.
9
+ **avatar**, reorder/hide, multi-resume management, **backup/restore**, publish, and **PDF export**.
10
10
  It drives FlowCV's private JSON API (the same calls the web app makes), so it
11
11
  works for any FlowCV resume with your own session. **Zero dependencies** (Python
12
12
  standard library only), so it's easy to drop into scripts and LLM agents.
@@ -66,10 +66,13 @@ flowcv duplicate ["Copy title"] # full copy of the current resume
66
66
  flowcv rename "New Title" # rename the current resume
67
67
  flowcv delete-resume --yes # permanent (refuses without --yes)
68
68
 
69
- # content (markdown mini-format below); `add` creates the section if needed
69
+ # content (markdown mini-format below); `add` creates the section if needed.
70
+ # --set aliases (title/company/link) resolve per section (work->jobTitle, publication->title…)
70
71
  flowcv add work --set title="Engineer" --set company="Acme" \
71
72
  --set start=01/2022 --set end=Present --text $'- Did a measurable thing.'
72
- flowcv desc work <id> --file role.md
73
+ flowcv add custom2 --section-name "Open Source" --icon code --text "…" # heading+icon at creation
74
+ flowcv desc work <id> --file role.md # rich text; --field is optional (auto: profile=text, skill=infoHtml)
75
+ flowcv date publication <id> --year 2018 # structured date (year-only by default)
73
76
  flowcv field work <id> employer --text "Acme Corp"
74
77
  flowcv rm work <id>
75
78
 
@@ -79,7 +82,7 @@ flowcv hide work <id> ; flowcv show-entry work <id>
79
82
  flowcv rename-section skill "Core Skills"
80
83
  flowcv section-icon skill head-side-brain
81
84
  flowcv rm-section custom1 --yes # delete a section + its entries
82
- flowcv reorder-sections profile work skill education # one-column order
85
+ flowcv reorder-sections profile work skill education # set one-column order (no args = print current)
83
86
 
84
87
  # header details & links (links are social entries: orcid, googlescholar, github…)
85
88
  flowcv pd jobTitle --text "Security Leader"
@@ -101,6 +104,10 @@ flowcv download -o resume.pdf # the rendered PDF
101
104
  flowcv download --token <webToken> -o out.pdf # any PUBLIC resume by its share token (no auth)
102
105
  flowcv share | publish | unpublish
103
106
 
107
+ # backup / restore
108
+ flowcv export -o backup.json # full resume snapshot (JSON) — keep one before big edits
109
+ flowcv import backup.json # restore the snapshot into a NEW resume (non-destructive)
110
+
104
111
  flowcv login # refresh the cached session
105
112
  flowcv md2html --file role.md # preview HTML (offline)
106
113
  ```
@@ -129,6 +136,12 @@ fc.rename_section("skill", "Core Skills"); fc.delete_section("custom1")
129
136
  fc.hide_entry("work", "id", hidden=True)
130
137
  new_id = fc.create_resume("Second Resume") # or fc.duplicate_resume()
131
138
  fc.rename_resume("New Title"); fc.delete_resume() # delete is permanent
139
+ fc.set_date("publication", "id", year=2018) # structured date (year-only)
140
+
141
+ # backup / restore
142
+ import json
143
+ json.dump(fc.export_resume(), open("backup.json", "w")) # full snapshot
144
+ new_id = fc.import_resume(json.load(open("backup.json"))) # restore into a NEW resume
132
145
  ```
133
146
 
134
147
  ### Build → render → check → improve
@@ -143,9 +156,11 @@ feedback loop for building a resume from raw info.
143
156
  |---|---|
144
157
  | blank line | block separator |
145
158
  | `## Heading` / `**Whole line bold**` | bold subheader |
159
+ | `***Whole line***` | bold + italic subheader |
146
160
  | `- item` | bullet (consecutive = one list) |
147
161
  | anything else | justified paragraph |
148
- | `**bold**` inline | `<strong>bold</strong>` |
162
+ | `**bold**` / `***bold-italic***` inline | `<strong>` / `<strong><em>…</em></strong>` |
163
+ | `[text](url)` inline | link |
149
164
 
150
165
  ## How it works
151
166
 
@@ -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.2.0"
8
+ __version__ = "0.6.0"
@@ -8,18 +8,36 @@ or pass `--resume-id <id>` (any command accepts it).
8
8
  import argparse
9
9
  import json
10
10
  import os
11
+ import re
11
12
  import sys
12
13
 
13
14
  from .api import FlowCV
14
15
  from .client import login as do_login, _write_session, _jar_header
15
- from .content import SECTION_META, label_of
16
+ from .content import SECTION_META, label_of, rich_field
16
17
  from .markup import html_to_text, md_to_html
17
18
 
18
- ALIASES = {"title": "jobTitle", "company": "employer", "start": "startDateNew",
19
- "end": "endDateNew", "link": "employerLink"}
19
+ # `--set key=value` friendly aliases. `start`/`end` are common to all sections;
20
+ # `title`/`company`/`link` map to different real fields per section, so resolve
21
+ # them section-aware (the old flat map mis-set custom/publication entries).
22
+ _ALIASES_COMMON = {"start": "startDateNew", "end": "endDateNew"}
23
+ _ALIASES_BY_SECTION = {
24
+ "work": {"title": "jobTitle", "company": "employer", "link": "employerLink"},
25
+ "education": {"title": "degree", "company": "school", "link": "schoolLink"},
26
+ "publication": {"title": "title", "company": "publisher", "link": "titleLink"},
27
+ "organisation": {"title": "position", "company": "organisationName", "link": "organisationLink"},
28
+ "custom": {"title": "title", "link": "titleLink"},
29
+ }
20
30
  TEXT_FIELDS = ("description", "infoHtml", "text")
21
31
 
22
32
 
33
+ def _resolve_set_key(section, key):
34
+ """Map a friendly --set key to the real entry field for `section`."""
35
+ if key in _ALIASES_COMMON:
36
+ return _ALIASES_COMMON[key]
37
+ sec = "custom" if re.fullmatch(r"custom\d+", section or "") else section
38
+ return _ALIASES_BY_SECTION.get(sec, {}).get(key, key)
39
+
40
+
23
41
  def _read(file=None, text=None):
24
42
  if file:
25
43
  with open(file) as f:
@@ -111,8 +129,9 @@ def cmd_add(a):
111
129
  if "=" not in kv:
112
130
  sys.exit(f"--set expects key=value, got {kv!r}")
113
131
  k, _, v = kv.partition("=")
114
- sets[ALIASES.get(k, k)] = v
115
- new_id = _fc(a).add_entry(a.section, sets=sets, md=_read(a.file, a.text))
132
+ sets[_resolve_set_key(a.section, k)] = v
133
+ new_id = _fc(a).add_entry(a.section, sets=sets, md=_read(a.file, a.text),
134
+ section_name=a.section_name, section_icon=a.icon)
116
135
  print(f"added {a.section} entry -> {new_id}")
117
136
 
118
137
 
@@ -148,7 +167,24 @@ def cmd_rm_section(a):
148
167
 
149
168
 
150
169
  def cmd_reorder_sections(a):
151
- _result(_fc(a).reorder_sections(a.ids, layout=a.layout), f"reorder-sections ({a.layout})")
170
+ fc = _fc(a)
171
+ if not a.ids: # no ids -> print current order
172
+ resume = fc.get_resume()
173
+ so = (resume.get("customization") or {}).get("sectionOrder") or {}
174
+ content = resume.get("content") or {}
175
+ name = lambda sid: (content.get(sid) or {}).get("displayName", "")
176
+ print(f"section order ({a.layout}):")
177
+ if a.layout == "two":
178
+ lay = so.get("two") or {}
179
+ for side in ("leftSectionsSorted", "rightSectionsSorted"):
180
+ print(f" {side}:")
181
+ for sid in lay.get(side) or []:
182
+ print(f" {sid} {name(sid)}")
183
+ else:
184
+ for sid in (so.get(a.layout) or {}).get("sectionsSorted") or []:
185
+ print(f" {sid} {name(sid)}")
186
+ return
187
+ _result(fc.reorder_sections(a.ids, layout=a.layout), f"reorder-sections ({a.layout})")
152
188
 
153
189
 
154
190
  def cmd_field(a):
@@ -157,8 +193,28 @@ def cmd_field(a):
157
193
 
158
194
 
159
195
  def cmd_desc(a):
160
- _result(_fc(a).set_description(a.section, a.entry, _read(a.file, a.text), field=a.field),
161
- f"{a.section}/{a.entry[:8]}.{a.field}")
196
+ field = a.field or rich_field(a.section) # auto: profile->text, skill->infoHtml
197
+ _result(_fc(a).set_description(a.section, a.entry, _read(a.file, a.text), field=field),
198
+ f"{a.section}/{a.entry[:8]}.{field}")
199
+
200
+
201
+ def cmd_date(a):
202
+ _result(_fc(a).set_date(a.section, a.entry, year=a.year, month=a.month, day=a.day),
203
+ f"{a.section}/{a.entry[:8]}.date")
204
+
205
+
206
+ def cmd_export(a):
207
+ out = a.output or "resume-backup.json"
208
+ with open(out, "w") as f:
209
+ json.dump(_fc(a).export_resume(), f, indent=2, ensure_ascii=False)
210
+ print(f"exported resume -> {out} ({os.path.getsize(out)} bytes)")
211
+
212
+
213
+ def cmd_import(a):
214
+ with open(a.file) as f:
215
+ data = json.load(f)
216
+ new_id = _fc(a).import_resume(data, title=a.title)
217
+ print(f"restored backup into a NEW resume -> {new_id} (current resume untouched)")
162
218
 
163
219
 
164
220
  def cmd_pd(a):
@@ -271,7 +327,9 @@ def build_parser():
271
327
  s = add("dump"); s.add_argument("section"); s.add_argument("entry"); s.set_defaults(fn=cmd_dump)
272
328
 
273
329
  s = add("add"); s.add_argument("section")
274
- s.add_argument("--set", action="append", help="field=value (aliases: title,company,start,end,link)")
330
+ s.add_argument("--set", action="append", help="field=value (section-aware aliases: title,company,start,end,link)")
331
+ s.add_argument("--section-name", help="display name to use if this creates a new section")
332
+ s.add_argument("--icon", help="icon key to use if this creates a new section, e.g. code")
275
333
  g = s.add_mutually_exclusive_group(); g.add_argument("--file"); g.add_argument("--text")
276
334
  s.set_defaults(fn=cmd_add)
277
335
 
@@ -288,8 +346,9 @@ def build_parser():
288
346
  s = add("rm-section"); s.add_argument("section")
289
347
  s.add_argument("--yes", action="store_true", help="confirm deleting the section + its entries")
290
348
  s.set_defaults(fn=cmd_rm_section)
291
- s = add("reorder-sections"); s.add_argument("ids", nargs="+", help="section ids in the desired order")
292
- s.add_argument("--layout", default="one", help="column layout to reorder (one|two|mix; default one)")
349
+ s = add("reorder-sections")
350
+ s.add_argument("ids", nargs="*", help="section ids in the desired order (omit to print the current order)")
351
+ s.add_argument("--layout", default="one", help="column layout (one|two|mix; default one)")
293
352
  s.set_defaults(fn=cmd_reorder_sections)
294
353
 
295
354
  s = add("field"); s.add_argument("section"); s.add_argument("entry"); s.add_argument("field")
@@ -297,10 +356,15 @@ def build_parser():
297
356
  s.set_defaults(fn=cmd_field)
298
357
 
299
358
  s = add("desc"); s.add_argument("section"); s.add_argument("entry")
300
- s.add_argument("--field", default="description")
359
+ s.add_argument("--field", default=None,
360
+ help="rich-text field (default: auto by section — profile=text, skill=infoHtml, else description)")
301
361
  g = s.add_mutually_exclusive_group(required=True); g.add_argument("--file"); g.add_argument("--text")
302
362
  s.set_defaults(fn=cmd_desc)
303
363
 
364
+ s = add("date"); s.add_argument("section"); s.add_argument("entry")
365
+ s.add_argument("--year"); s.add_argument("--month"); s.add_argument("--day")
366
+ s.set_defaults(fn=cmd_date)
367
+
304
368
  s = add("pd"); s.add_argument("field")
305
369
  g = s.add_mutually_exclusive_group(required=True); g.add_argument("--text"); g.add_argument("--file")
306
370
  s.set_defaults(fn=cmd_pd)
@@ -324,6 +388,12 @@ def build_parser():
324
388
  add("unpublish").set_defaults(fn=cmd_unpublish)
325
389
  add("share").set_defaults(fn=cmd_share)
326
390
 
391
+ s = add("export"); s.add_argument("-o", "--output", help="output JSON file (default resume-backup.json)")
392
+ s.set_defaults(fn=cmd_export)
393
+ s = add("import"); s.add_argument("file", help="a JSON backup produced by `export`")
394
+ s.add_argument("--title", help="title for the restored resume (default: '<name> (restored)')")
395
+ s.set_defaults(fn=cmd_import)
396
+
327
397
  s = add("md2html")
328
398
  g = s.add_mutually_exclusive_group(required=True); g.add_argument("--file"); g.add_argument("--text")
329
399
  s.set_defaults(fn=cmd_md2html)
@@ -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
@@ -7,6 +7,7 @@ read-modify-write where the API replaces the whole entry; create uses
7
7
 
8
8
  Mixes into Client; uses self.get_resume / self.request / self.resume_id.
9
9
  """
10
+ import re
10
11
  import uuid
11
12
 
12
13
  from .markup import md_to_html
@@ -22,6 +23,15 @@ SECTION_META = {
22
23
  "custom1": ("custom", "Custom", "star"),
23
24
  }
24
25
 
26
+ # Per-section rich-text field: most sections store rich text in `description`,
27
+ # but the summary uses `text` and skills use `infoHtml`.
28
+ RICH_TEXT_FIELD = {"profile": "text", "skill": "infoHtml"}
29
+
30
+
31
+ def rich_field(section):
32
+ """The entry field that holds rich text for `section` (default 'description')."""
33
+ return RICH_TEXT_FIELD.get(section, "description")
34
+
25
35
 
26
36
  def label_of(entry):
27
37
  """Best human label for an entry across section shapes."""
@@ -65,12 +75,14 @@ class ContentMixin:
65
75
  return self.request("resumes/delete_entry", method="DELETE", query=query)
66
76
 
67
77
  # ---- high-level helpers ----------------------------------------------
68
- def add_entry(self, section, sets=None, md=None):
78
+ def add_entry(self, section, sets=None, md=None, section_name=None, section_icon=None):
69
79
  """Create an entry (and the section if needed); return the new id.
70
80
 
71
- `sets` are entry fields to populate; `md` becomes a `description`
72
- HTML field. Creates the entry first (so a missing section is made),
73
- then fills it with a follow-up update.
81
+ `sets` are entry fields to populate; `md` becomes the section's rich-text
82
+ field. Creates the entry first (so a missing section is made), then fills
83
+ it with a follow-up update. When the section is created, `section_name`
84
+ and `section_icon` override its default heading/icon (no follow-up
85
+ rename-section / section-icon needed).
74
86
  """
75
87
  resume = self.get_resume()
76
88
  existing = (resume.get("content") or {}).get(section)
@@ -80,8 +92,17 @@ class ContentMixin:
80
92
  icon_key = existing.get("iconKey")
81
93
  elif section in SECTION_META:
82
94
  section_type, display_name, icon_key = SECTION_META[section]
95
+ elif re.fullmatch(r"custom\d+", section):
96
+ # FlowCV supports multiple custom sections (custom1, custom2, …);
97
+ # only custom1 is pre-declared in SECTION_META. Create any other
98
+ # customN as a generic custom section.
99
+ section_type, display_name, icon_key = "custom", "Custom", "star"
83
100
  else:
84
101
  raise SystemExit(f"unknown section (no meta): {section}")
102
+ if section_name is not None:
103
+ display_name = section_name
104
+ if section_icon is not None:
105
+ icon_key = section_icon
85
106
 
86
107
  new_id = str(uuid.uuid4())
87
108
  # Create: minimal entry + section meta (also creates the section).
@@ -98,9 +119,7 @@ class ContentMixin:
98
119
  "createdAt": now, "updatedAt": now}
99
120
  entry.update(sets or {})
100
121
  if md:
101
- # rich text lives in a section-specific field (profile->text, skill->infoHtml)
102
- field = {"profile": "text", "skill": "infoHtml"}.get(section, "description")
103
- entry[field] = md_to_html(md)
122
+ entry[rich_field(section)] = md_to_html(md)
104
123
  env = self.save_entry(section, entry)
105
124
  if not env.get("success"):
106
125
  raise SystemExit(f"created {section} entry {new_id} but failed to populate it: {env}")
@@ -115,9 +134,30 @@ class ContentMixin:
115
134
  entry["updatedAt"] = self.now_iso()
116
135
  return self.save_entry(section, entry)
117
136
 
118
- def set_description(self, section, entry_id, md, field="description"):
119
- """Set a rich-text field to md_to_html(md) on an entry."""
120
- return self.set_field(section, entry_id, field, md_to_html(md))
137
+ def set_description(self, section, entry_id, md, field=None):
138
+ """Set a rich-text field to md_to_html(md) on an entry. When `field` is
139
+ omitted, it defaults to the section's rich-text field (profile->text,
140
+ skill->infoHtml, else description)."""
141
+ return self.set_field(section, entry_id, field or rich_field(section), md_to_html(md))
142
+
143
+ def set_date(self, section, entry_id, year=None, month=None, day=None):
144
+ """Set an entry's structured `date` object (used by publications, etc.).
145
+
146
+ Merges into the existing date; month/day left unset are hidden, so
147
+ `set_date(..., year=2018)` renders as a year-only date.
148
+ """
149
+ resume = self.get_resume()
150
+ entry = dict(self.find_entry(resume, section, entry_id))
151
+ date = dict(entry.get("date") or {})
152
+ date["year"] = "" if year is None else str(year)
153
+ date["month"] = "" if month is None else str(month)
154
+ date["day"] = "" if day is None else str(day)
155
+ date["hideMonth"] = month is None
156
+ date["hideDay"] = day is None
157
+ entry["date"] = date
158
+ if "updatedAt" in entry:
159
+ entry["updatedAt"] = self.now_iso()
160
+ return self.save_entry(section, entry)
121
161
 
122
162
  def hide_entry(self, section, entry_id, hidden=True):
123
163
  """Show/hide an entry (sets its `isHidden`). Hidden entries stay in the
@@ -6,9 +6,11 @@ converts a small markdown dialect into that markup:
6
6
  blank line -> block separator
7
7
  "## Heading" -> bold justified paragraph (a subheader)
8
8
  "**Whole line bold**" -> bold justified paragraph
9
+ "***Whole line***" -> bold + italic justified paragraph (a sub-subheader)
9
10
  "- item" -> bullet (consecutive lines become one <ul>)
10
11
  anything else -> justified paragraph
11
12
  inline **bold** -> <strong>bold</strong> (inside paragraphs and bullets)
13
+ inline [text](url) -> <a href="url">text</a> (inside paragraphs and bullets)
12
14
  """
13
15
  import html
14
16
  import re
@@ -17,8 +19,13 @@ J = ' style="text-align: justify"'
17
19
 
18
20
 
19
21
  def _esc(s):
20
- """Escape text, then honor inline **bold**."""
21
- return re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", html.escape(s, quote=False))
22
+ """Escape text, then honor inline ***bold-italic***, **bold**, and
23
+ [text](url) links (triple-asterisks before double so they don't clash)."""
24
+ s = html.escape(s, quote=False)
25
+ s = re.sub(r"\*\*\*(.+?)\*\*\*", r"<strong><em>\1</em></strong>", s)
26
+ s = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s)
27
+ return re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)",
28
+ r'<a target="_blank" rel="noopener noreferrer nofollow" href="\2">\1</a>', s)
22
29
 
23
30
 
24
31
  def md_to_html(md):
@@ -41,6 +48,8 @@ def md_to_html(md):
41
48
  flush()
42
49
  if line.startswith("## "):
43
50
  parts.append(f"<p{J}><strong>{html.escape(line[3:].strip(), quote=False)}</strong></p>")
51
+ elif len(line) > 6 and line.startswith("***") and line.endswith("***"):
52
+ parts.append(f"<p{J}><strong><em>{html.escape(line[3:-3].strip(), quote=False)}</em></strong></p>")
44
53
  elif len(line) > 4 and line.startswith("**") and line.endswith("**") and line.count("**") == 2:
45
54
  parts.append(f"<p{J}><strong>{html.escape(line[2:-2].strip(), quote=False)}</strong></p>")
46
55
  else:
@@ -23,17 +23,17 @@ class ResumeMixin:
23
23
  return env["data"]["resumes"]
24
24
 
25
25
  # ---- create / duplicate / rename / delete -----------------------------
26
- def _create_from(self, title, keep_content):
27
- """Create a new resume by cloning the current one's full object.
26
+ def _create_from(self, title, keep_content, src=None):
27
+ """Create a new resume by cloning a full resume object.
28
28
 
29
29
  FlowCV's `create` needs a complete resume object (every NOT-NULL column),
30
- so we clone the current resumeguaranteeing validity — then reassign a
31
- fresh id/uuid, drop the unique tokens (server regenerates), and set the
32
- title. `keep_content=False` makes a blank resume that keeps the same
33
- identity (personalDetails) and design (customization); `keep_content=True`
34
- is a full duplicate. Returns the new resume id.
30
+ so we clone a valid one`src` (defaults to the current resume) — then
31
+ reassign a fresh id/uuid, drop the unique tokens (server regenerates), and
32
+ set the title. `keep_content=False` makes a blank resume that keeps the
33
+ same identity (personalDetails) and design (customization);
34
+ `keep_content=True` is a full copy. Returns the new resume id.
35
35
  """
36
- src = self.get_resume()
36
+ src = self.get_resume() if src is None else src
37
37
  clone = json.loads(json.dumps(src)) # deep copy
38
38
  new_id = str(uuid.uuid4())
39
39
  clone["id"] = new_id
@@ -59,6 +59,19 @@ class ResumeMixin:
59
59
  title = (self.get_resume().get("title") or "Resume") + " (copy)"
60
60
  return self._create_from(title, keep_content=True)
61
61
 
62
+ # ---- backup / restore -------------------------------------------------
63
+ def export_resume(self):
64
+ """Return the full resume object (for a backup/snapshot)."""
65
+ return self.get_resume()
66
+
67
+ def import_resume(self, resume, title=None):
68
+ """Restore a previously exported resume object into a NEW resume
69
+ (non-destructive — the current resume is untouched). Returns the new id.
70
+ """
71
+ if title is None:
72
+ title = (resume.get("title") or "Resume") + " (restored)"
73
+ return self._create_from(title, keep_content=True, src=resume)
74
+
62
75
  def rename_resume(self, title):
63
76
  """PATCH /resumes/rename_resume — set the resume's title."""
64
77
  return self.request("resumes/rename_resume", method="PATCH",
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowcvcli
3
- Version: 0.2.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. 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,17 +26,18 @@ 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
32
33
 
33
34
  [![PyPI](https://img.shields.io/pypi/v/flowcvcli.svg)](https://pypi.org/project/flowcvcli/)
34
- [![Python](https://img.shields.io/pypi/pyversions/flowcvcli.svg)](https://pypi.org/project/flowcvcli/)
35
+ [![Python](https://img.shields.io/badge/python-3.8%2B-blue.svg)](https://pypi.org/project/flowcvcli/)
35
36
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
36
37
 
37
38
  Control a [FlowCV](https://flowcv.com) resume from the **command line** or from
38
39
  **Python** — content, header & links, **customization**, **templates**,
39
- **avatar**, reorder/hide, multi-resume management, publish, and **PDF export**.
40
+ **avatar**, reorder/hide, multi-resume management, **backup/restore**, publish, and **PDF export**.
40
41
  It drives FlowCV's private JSON API (the same calls the web app makes), so it
41
42
  works for any FlowCV resume with your own session. **Zero dependencies** (Python
42
43
  standard library only), so it's easy to drop into scripts and LLM agents.
@@ -96,10 +97,13 @@ flowcv duplicate ["Copy title"] # full copy of the current resume
96
97
  flowcv rename "New Title" # rename the current resume
97
98
  flowcv delete-resume --yes # permanent (refuses without --yes)
98
99
 
99
- # content (markdown mini-format below); `add` creates the section if needed
100
+ # content (markdown mini-format below); `add` creates the section if needed.
101
+ # --set aliases (title/company/link) resolve per section (work->jobTitle, publication->title…)
100
102
  flowcv add work --set title="Engineer" --set company="Acme" \
101
103
  --set start=01/2022 --set end=Present --text $'- Did a measurable thing.'
102
- flowcv desc work <id> --file role.md
104
+ flowcv add custom2 --section-name "Open Source" --icon code --text "…" # heading+icon at creation
105
+ flowcv desc work <id> --file role.md # rich text; --field is optional (auto: profile=text, skill=infoHtml)
106
+ flowcv date publication <id> --year 2018 # structured date (year-only by default)
103
107
  flowcv field work <id> employer --text "Acme Corp"
104
108
  flowcv rm work <id>
105
109
 
@@ -109,7 +113,7 @@ flowcv hide work <id> ; flowcv show-entry work <id>
109
113
  flowcv rename-section skill "Core Skills"
110
114
  flowcv section-icon skill head-side-brain
111
115
  flowcv rm-section custom1 --yes # delete a section + its entries
112
- flowcv reorder-sections profile work skill education # one-column order
116
+ flowcv reorder-sections profile work skill education # set one-column order (no args = print current)
113
117
 
114
118
  # header details & links (links are social entries: orcid, googlescholar, github…)
115
119
  flowcv pd jobTitle --text "Security Leader"
@@ -131,6 +135,10 @@ flowcv download -o resume.pdf # the rendered PDF
131
135
  flowcv download --token <webToken> -o out.pdf # any PUBLIC resume by its share token (no auth)
132
136
  flowcv share | publish | unpublish
133
137
 
138
+ # backup / restore
139
+ flowcv export -o backup.json # full resume snapshot (JSON) — keep one before big edits
140
+ flowcv import backup.json # restore the snapshot into a NEW resume (non-destructive)
141
+
134
142
  flowcv login # refresh the cached session
135
143
  flowcv md2html --file role.md # preview HTML (offline)
136
144
  ```
@@ -159,6 +167,12 @@ fc.rename_section("skill", "Core Skills"); fc.delete_section("custom1")
159
167
  fc.hide_entry("work", "id", hidden=True)
160
168
  new_id = fc.create_resume("Second Resume") # or fc.duplicate_resume()
161
169
  fc.rename_resume("New Title"); fc.delete_resume() # delete is permanent
170
+ fc.set_date("publication", "id", year=2018) # structured date (year-only)
171
+
172
+ # backup / restore
173
+ import json
174
+ json.dump(fc.export_resume(), open("backup.json", "w")) # full snapshot
175
+ new_id = fc.import_resume(json.load(open("backup.json"))) # restore into a NEW resume
162
176
  ```
163
177
 
164
178
  ### Build → render → check → improve
@@ -173,9 +187,11 @@ feedback loop for building a resume from raw info.
173
187
  |---|---|
174
188
  | blank line | block separator |
175
189
  | `## Heading` / `**Whole line bold**` | bold subheader |
190
+ | `***Whole line***` | bold + italic subheader |
176
191
  | `- item` | bullet (consecutive = one list) |
177
192
  | anything else | justified paragraph |
178
- | `**bold**` inline | `<strong>bold</strong>` |
193
+ | `**bold**` / `***bold-italic***` inline | `<strong>` / `<strong><em>…</em></strong>` |
194
+ | `[text](url)` inline | link |
179
195
 
180
196
  ## How it works
181
197
 
@@ -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