evo-cli 0.1.11__tar.gz → 0.2.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 (31) hide show
  1. {evo_cli-0.1.11 → evo_cli-0.2.0}/PKG-INFO +1 -1
  2. evo_cli-0.2.0/evo_cli/VERSION +1 -0
  3. {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli/cli.py +4 -0
  4. evo_cli-0.2.0/evo_cli/commands/gdrive.py +528 -0
  5. evo_cli-0.2.0/evo_cli/commands/site2s.py +289 -0
  6. {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli.egg-info/PKG-INFO +1 -1
  7. {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli.egg-info/SOURCES.txt +2 -0
  8. evo_cli-0.1.11/evo_cli/VERSION +0 -1
  9. {evo_cli-0.1.11 → evo_cli-0.2.0}/Containerfile +0 -0
  10. {evo_cli-0.1.11 → evo_cli-0.2.0}/HISTORY.md +0 -0
  11. {evo_cli-0.1.11 → evo_cli-0.2.0}/LICENSE +0 -0
  12. {evo_cli-0.1.11 → evo_cli-0.2.0}/MANIFEST.in +0 -0
  13. {evo_cli-0.1.11 → evo_cli-0.2.0}/README.md +0 -0
  14. {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli/__init__.py +0 -0
  15. {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli/__main__.py +0 -0
  16. {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli/base.py +0 -0
  17. {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli/commands/__init__.py +0 -0
  18. {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli/commands/cloudflare.py +0 -0
  19. {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli/commands/fix_claude.py +0 -0
  20. {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli/commands/miniconda.py +0 -0
  21. {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli/commands/ssh.py +0 -0
  22. {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli/console.py +0 -0
  23. {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli.egg-info/dependency_links.txt +0 -0
  24. {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli.egg-info/entry_points.txt +0 -0
  25. {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli.egg-info/requires.txt +0 -0
  26. {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli.egg-info/top_level.txt +0 -0
  27. {evo_cli-0.1.11 → evo_cli-0.2.0}/pyproject.toml +0 -0
  28. {evo_cli-0.1.11 → evo_cli-0.2.0}/setup.cfg +0 -0
  29. {evo_cli-0.1.11 → evo_cli-0.2.0}/tests/__init__.py +0 -0
  30. {evo_cli-0.1.11 → evo_cli-0.2.0}/tests/test_cli.py +0 -0
  31. {evo_cli-0.1.11 → evo_cli-0.2.0}/tests/test_fix_claude.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.1.11
3
+ Version: 0.2.0
4
4
  Summary: Evolution CLI - a developer toolbox for setting up dev machines
5
5
  Author: maycuatroi
6
6
  Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
@@ -0,0 +1 @@
1
+ 0.2.0
@@ -3,7 +3,9 @@ import rich_click as click
3
3
  from evo_cli import __version__
4
4
  from evo_cli.commands.cloudflare import cfssh
5
5
  from evo_cli.commands.fix_claude import f_claude
6
+ from evo_cli.commands.gdrive import gdrive
6
7
  from evo_cli.commands.miniconda import miniconda
8
+ from evo_cli.commands.site2s import site2s
7
9
  from evo_cli.commands.ssh import setupssh
8
10
 
9
11
  click.rich_click.USE_MARKDOWN = True
@@ -31,6 +33,8 @@ cli.add_command(setupssh)
31
33
  cli.add_command(miniconda)
32
34
  cli.add_command(cfssh)
33
35
  cli.add_command(f_claude)
36
+ cli.add_command(gdrive)
37
+ cli.add_command(site2s)
34
38
 
35
39
 
36
40
  def main():
@@ -0,0 +1,528 @@
1
+ import base64
2
+ import json
3
+ import mimetypes
4
+ import os
5
+ import re
6
+ import sys
7
+ import tempfile
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from datetime import datetime, timedelta, timezone
12
+ from pathlib import Path
13
+
14
+ import rich_click as click
15
+ from rich.text import Text
16
+
17
+ from evo_cli.console import error, info, step, success, warning
18
+
19
+ CONFIG_PATH = Path(os.environ.get("OMELET_CONFIG", str(Path.home() / ".omelet.json")))
20
+ TOKEN_URL = "https://oauth2.googleapis.com/token"
21
+ DOCS_API = "https://docs.googleapis.com/v1/documents/{doc_id}"
22
+ DRIVE_FILE_API = "https://www.googleapis.com/drive/v3/files/{file_id}"
23
+ DRIVE_EXPORT_API = "https://www.googleapis.com/drive/v3/files/{file_id}/export"
24
+
25
+ DOC_ID_RE = re.compile(r"/document/d/([a-zA-Z0-9_-]+)")
26
+ RAW_ID_RE = re.compile(r"^[a-zA-Z0-9_-]{20,}$")
27
+ MD_INLINE_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\((https?://[^)\s]+)\)")
28
+ MD_REF_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\[([^\]]+)\]")
29
+ MD_REF_DEF_RE = re.compile(r"^\[([^\]]+)\]:\s*<?([^>\s]+)>?\s*$", re.MULTILINE)
30
+ DATA_URI_RE = re.compile(r"^data:([^;,]+)(?:;([^,]+))?,(.*)$", re.DOTALL)
31
+
32
+ EPILOG = Text.from_markup(
33
+ "[bold]Examples[/bold]\n\n"
34
+ " [cyan]evo gdrive doc-read <url>[/cyan] read doc, write `<title>/doc.md` + `<title>/images/`\n"
35
+ " [cyan]evo gdrive doc-read <url> -o ./out[/cyan] write into ./out instead\n"
36
+ " [cyan]evo gdrive doc-read <id> --no-images[/cyan] skip image download\n"
37
+ " [cyan]evo gdrive doc-read <url> --via-docs-api[/cyan] use Docs API (needs Docs API enabled in the OAuth project)\n"
38
+ " [cyan]evo gdrive doc-read <url> --raw[/cyan] also dump the raw API response"
39
+ )
40
+
41
+
42
+ def extract_doc_id(value):
43
+ match = DOC_ID_RE.search(value)
44
+ if match:
45
+ return match.group(1)
46
+ if RAW_ID_RE.match(value):
47
+ return value
48
+ return None
49
+
50
+
51
+ def read_config():
52
+ if not CONFIG_PATH.exists():
53
+ raise click.ClickException(f"config not found: {CONFIG_PATH}")
54
+ return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
55
+
56
+
57
+ def write_config(data):
58
+ tmp = tempfile.NamedTemporaryFile("w", delete=False, dir=str(CONFIG_PATH.parent), encoding="utf-8")
59
+ try:
60
+ json.dump(data, tmp, indent=2, ensure_ascii=False)
61
+ tmp.write("\n")
62
+ tmp.close()
63
+ os.chmod(tmp.name, 0o600)
64
+ os.replace(tmp.name, CONFIG_PATH)
65
+ except Exception:
66
+ if os.path.exists(tmp.name):
67
+ os.unlink(tmp.name)
68
+ raise
69
+
70
+
71
+ def token_expired(token_section):
72
+ expiry = token_section.get("expiry")
73
+ if not expiry:
74
+ return True
75
+ try:
76
+ when = datetime.fromisoformat(expiry)
77
+ except ValueError:
78
+ return True
79
+ if when.tzinfo is None:
80
+ when = when.replace(tzinfo=timezone.utc)
81
+ now = datetime.now(timezone.utc)
82
+ return (when - now).total_seconds() < 60
83
+
84
+
85
+ def refresh_token(data):
86
+ rclone_section = data.get("rclone", {})
87
+ token_section = rclone_section.get("token", {})
88
+ refresh = token_section.get("refresh_token")
89
+ if not refresh:
90
+ raise click.ClickException("rclone.token.refresh_token missing in config")
91
+
92
+ client_id = os.environ.get("RCLONE_DRIVE_CLIENT_ID") or rclone_section.get("client_id")
93
+ client_secret = os.environ.get("RCLONE_DRIVE_CLIENT_SECRET") or rclone_section.get("client_secret")
94
+ if not client_id or not client_secret:
95
+ raise click.ClickException(
96
+ "OAuth client credentials required. Set RCLONE_DRIVE_CLIENT_ID + RCLONE_DRIVE_CLIENT_SECRET "
97
+ "or add rclone.client_id + rclone.client_secret to the config."
98
+ )
99
+
100
+ payload = urllib.parse.urlencode(
101
+ {
102
+ "client_id": client_id,
103
+ "client_secret": client_secret,
104
+ "refresh_token": refresh,
105
+ "grant_type": "refresh_token",
106
+ }
107
+ ).encode("utf-8")
108
+ req = urllib.request.Request(TOKEN_URL, data=payload, method="POST")
109
+ req.add_header("Content-Type", "application/x-www-form-urlencoded")
110
+
111
+ try:
112
+ with urllib.request.urlopen(req, timeout=30) as resp:
113
+ body = json.loads(resp.read().decode("utf-8"))
114
+ except urllib.error.HTTPError as exc:
115
+ err_body = exc.read().decode("utf-8", "replace")
116
+ raise click.ClickException(f"token refresh failed: HTTP {exc.code} {err_body}") from exc
117
+
118
+ access = body.get("access_token")
119
+ if not access:
120
+ raise click.ClickException(f"no access_token in refresh response: {body}")
121
+
122
+ expires_in = int(body.get("expires_in", 3600))
123
+ tz = timezone(timedelta(hours=7))
124
+ expiry = (datetime.now(tz) + timedelta(seconds=expires_in)).isoformat()
125
+
126
+ data["rclone"]["token"]["access_token"] = access
127
+ data["rclone"]["token"]["expiry"] = expiry
128
+ data["rclone"]["token"]["expires_in"] = expires_in
129
+ write_config(data)
130
+ info(f"Refreshed Drive access token (valid until [accent]{expiry}[/accent])")
131
+ return access
132
+
133
+
134
+ def access_token():
135
+ data = read_config()
136
+ token_section = data.get("rclone", {}).get("token", {})
137
+ token = token_section.get("access_token")
138
+ if not token or token_expired(token_section):
139
+ info("Access token missing or expired - refreshing.")
140
+ token = refresh_token(data)
141
+ return token
142
+
143
+
144
+ def http_get(url, token=None, timeout=120):
145
+ req = urllib.request.Request(url, method="GET")
146
+ if token:
147
+ req.add_header("Authorization", f"Bearer {token}")
148
+ req.add_header("User-Agent", "evo-cli")
149
+ try:
150
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
151
+ return resp.read(), dict(resp.headers)
152
+ except urllib.error.HTTPError as exc:
153
+ err_body = exc.read().decode("utf-8", "replace")
154
+ raise click.ClickException(f"GET {url} failed: HTTP {exc.code} {err_body}") from exc
155
+
156
+
157
+ def safe_filename(name):
158
+ cleaned = re.sub(r"[^a-zA-Z0-9._-]+", "_", name).strip("._-")
159
+ return cleaned or "image"
160
+
161
+
162
+ def guess_image_ext(content_type, fallback="png"):
163
+ if content_type:
164
+ ext = mimetypes.guess_extension(content_type.split(";")[0].strip()) or ""
165
+ ext = ext.lstrip(".")
166
+ if ext == "jpe":
167
+ ext = "jpg"
168
+ if ext:
169
+ return ext
170
+ return fallback
171
+
172
+
173
+ def download_image(url, dest_path, token=None):
174
+ headers = {"User-Agent": "evo-cli"}
175
+ if token:
176
+ headers["Authorization"] = f"Bearer {token}"
177
+ req = urllib.request.Request(url, headers=headers)
178
+ try:
179
+ with urllib.request.urlopen(req, timeout=120) as resp:
180
+ content_type = resp.headers.get("Content-Type", "")
181
+ body = resp.read()
182
+ except urllib.error.HTTPError as exc:
183
+ raise click.ClickException(f"image download failed: HTTP {exc.code} {url}") from exc
184
+ if not dest_path.suffix:
185
+ dest_path = dest_path.with_suffix(f".{guess_image_ext(content_type)}")
186
+ dest_path.write_bytes(body)
187
+ return dest_path
188
+
189
+
190
+ def fetch_file_metadata(file_id, token):
191
+ url = DRIVE_FILE_API.format(file_id=file_id) + "?" + urllib.parse.urlencode(
192
+ {"fields": "id,name,mimeType,modifiedTime"}
193
+ )
194
+ body, _ = http_get(url, token=token)
195
+ return json.loads(body.decode("utf-8"))
196
+
197
+
198
+ def parse_filename_from_disposition(headers):
199
+ disp = headers.get("Content-Disposition") or headers.get("content-disposition") or ""
200
+ match = re.search(r"filename\*=UTF-8''([^;]+)", disp)
201
+ if match:
202
+ return urllib.parse.unquote(match.group(1).strip().strip('"'))
203
+ match = re.search(r'filename="?([^";]+)"?', disp)
204
+ if match:
205
+ return match.group(1).strip()
206
+ return None
207
+
208
+
209
+ def drive_export_markdown(file_id, token):
210
+ url = DRIVE_EXPORT_API.format(file_id=file_id) + "?" + urllib.parse.urlencode({"mimeType": "text/markdown"})
211
+ body, headers = http_get(url, token=token)
212
+ return body.decode("utf-8"), parse_filename_from_disposition(headers)
213
+
214
+
215
+ def fetch_document(doc_id, token):
216
+ body, _ = http_get(DOCS_API.format(doc_id=doc_id), token=token)
217
+ return json.loads(body.decode("utf-8"))
218
+
219
+
220
+ def render_text_run(text_run):
221
+ content = text_run.get("content", "")
222
+ if not content:
223
+ return ""
224
+ style = text_run.get("textStyle") or {}
225
+ link = (style.get("link") or {}).get("url")
226
+ trailing_newline = content.endswith("\n")
227
+ body = content[:-1] if trailing_newline else content
228
+ if not body.strip():
229
+ return content
230
+ if style.get("bold"):
231
+ body = f"**{body}**"
232
+ if style.get("italic"):
233
+ body = f"*{body}*"
234
+ if style.get("strikethrough"):
235
+ body = f"~~{body}~~"
236
+ if style.get("underline") and not link:
237
+ body = f"<u>{body}</u>"
238
+ if link:
239
+ body = f"[{body}]({link})"
240
+ return body + ("\n" if trailing_newline else "")
241
+
242
+
243
+ def heading_prefix(named_style):
244
+ if not named_style:
245
+ return ""
246
+ if named_style == "TITLE":
247
+ return "# "
248
+ match = re.match(r"HEADING_(\d)", named_style)
249
+ if match:
250
+ level = max(1, min(6, int(match.group(1))))
251
+ return "#" * level + " "
252
+ return ""
253
+
254
+
255
+ def bullet_prefix(paragraph, list_props_by_id):
256
+ bullet = paragraph.get("bullet")
257
+ if not bullet:
258
+ return None
259
+ nesting = bullet.get("nestingLevel", 0)
260
+ indent = " " * nesting
261
+ list_props = list_props_by_id.get(bullet.get("listId"), {})
262
+ nesting_levels = list_props.get("nestingLevels", [])
263
+ glyph_type = None
264
+ if 0 <= nesting < len(nesting_levels):
265
+ glyph_type = nesting_levels[nesting].get("glyphType")
266
+ if glyph_type and glyph_type != "GLYPH_TYPE_UNSPECIFIED":
267
+ return f"{indent}1. "
268
+ return f"{indent}- "
269
+
270
+
271
+ def render_paragraph(paragraph, inline_objects, list_props_by_id, image_resolver):
272
+ elements = paragraph.get("elements") or []
273
+ style = paragraph.get("paragraphStyle") or {}
274
+ prefix = heading_prefix(style.get("namedStyleType"))
275
+ bullet = bullet_prefix(paragraph, list_props_by_id)
276
+ parts = []
277
+ for element in elements:
278
+ if "textRun" in element:
279
+ parts.append(render_text_run(element["textRun"]))
280
+ elif "inlineObjectElement" in element:
281
+ obj_id = element["inlineObjectElement"].get("inlineObjectId")
282
+ obj = inline_objects.get(obj_id, {})
283
+ embedded = (obj.get("inlineObjectProperties") or {}).get("embeddedObject") or {}
284
+ image_props = embedded.get("imageProperties") or {}
285
+ content_uri = image_props.get("contentUri")
286
+ alt = embedded.get("title") or embedded.get("description") or obj_id or "image"
287
+ if content_uri and image_resolver:
288
+ rel = image_resolver(obj_id, content_uri)
289
+ if rel:
290
+ parts.append(f"![{alt}]({rel})")
291
+ continue
292
+ parts.append(f"![{alt}]()")
293
+ elif "horizontalRule" in element:
294
+ parts.append("\n---\n")
295
+ elif "pageBreak" in element:
296
+ parts.append("\n\n---\n\n")
297
+ text = "".join(parts)
298
+ if bullet is not None:
299
+ text = bullet + text.lstrip()
300
+ elif prefix:
301
+ text = prefix + text.lstrip()
302
+ return text
303
+
304
+
305
+ def render_table(table, inline_objects, list_props_by_id, image_resolver):
306
+ rows = table.get("tableRows") or []
307
+ if not rows:
308
+ return ""
309
+ rendered_rows = []
310
+ for row in rows:
311
+ cells = row.get("tableCells") or []
312
+ rendered_cells = []
313
+ for cell in cells:
314
+ cell_parts = []
315
+ for element in cell.get("content") or []:
316
+ if "paragraph" in element:
317
+ cell_parts.append(
318
+ render_paragraph(
319
+ element["paragraph"], inline_objects, list_props_by_id, image_resolver
320
+ ).strip()
321
+ )
322
+ rendered_cells.append(" ".join(p for p in cell_parts if p).replace("|", "\\|"))
323
+ rendered_rows.append("| " + " | ".join(rendered_cells) + " |")
324
+ if rendered_rows:
325
+ header_cells = rendered_rows[0].count("|") - 1
326
+ separator = "| " + " | ".join(["---"] * max(1, header_cells)) + " |"
327
+ return "\n".join([rendered_rows[0], separator, *rendered_rows[1:]]) + "\n"
328
+ return ""
329
+
330
+
331
+ def document_to_markdown(document, image_resolver):
332
+ body = document.get("body") or {}
333
+ content = body.get("content") or []
334
+ inline_objects = document.get("inlineObjects") or {}
335
+ list_props_by_id = {
336
+ lid: lst.get("listProperties") or {} for lid, lst in (document.get("lists") or {}).items()
337
+ }
338
+ title = document.get("title") or "Document"
339
+ out_parts = [f"# {title}\n"]
340
+ for element in content:
341
+ if "paragraph" in element:
342
+ text = render_paragraph(element["paragraph"], inline_objects, list_props_by_id, image_resolver)
343
+ if text.strip():
344
+ out_parts.append(text.rstrip() + "\n")
345
+ elif "table" in element:
346
+ out_parts.append(render_table(element["table"], inline_objects, list_props_by_id, image_resolver))
347
+ return "\n".join(part for part in out_parts if part).rstrip() + "\n"
348
+
349
+
350
+ def build_image_resolver(inline_objects, out_dir, no_images):
351
+ if no_images or not inline_objects:
352
+ return None
353
+ images_dir = out_dir / "images"
354
+ images_dir.mkdir(parents=True, exist_ok=True)
355
+ cache = {}
356
+
357
+ def resolver(obj_id, content_uri):
358
+ if obj_id in cache:
359
+ return cache[obj_id]
360
+ dest = images_dir / safe_filename(obj_id)
361
+ try:
362
+ written = download_image(content_uri, dest)
363
+ except click.ClickException as exc:
364
+ warning(str(exc))
365
+ cache[obj_id] = None
366
+ return None
367
+ rel = f"images/{written.name}"
368
+ cache[obj_id] = rel
369
+ info(f"Saved image [accent]{rel}[/accent]")
370
+ return rel
371
+
372
+ return resolver
373
+
374
+
375
+ def save_data_uri(data_uri, dest_no_ext):
376
+ match = DATA_URI_RE.match(data_uri)
377
+ if not match:
378
+ return None
379
+ mime, encoding, payload = match.group(1), match.group(2) or "", match.group(3)
380
+ if "base64" in encoding.lower():
381
+ body = base64.b64decode(payload)
382
+ else:
383
+ body = urllib.parse.unquote_to_bytes(payload)
384
+ ext = guess_image_ext(mime)
385
+ dest = dest_no_ext.with_suffix(f".{ext}")
386
+ dest.write_bytes(body)
387
+ return dest
388
+
389
+
390
+ def materialize_image(source_url, dest_no_ext, token):
391
+ if source_url.startswith("data:"):
392
+ return save_data_uri(source_url, dest_no_ext)
393
+ use_token = token if "googleapis.com" in source_url else None
394
+ return download_image(source_url, dest_no_ext, token=use_token)
395
+
396
+
397
+ def download_markdown_images(markdown, out_dir, token):
398
+ images_dir = out_dir / "images"
399
+ cache = {}
400
+ index = [0]
401
+
402
+ def next_dest():
403
+ index[0] += 1
404
+ images_dir.mkdir(parents=True, exist_ok=True)
405
+ return images_dir / f"image_{index[0]:03d}"
406
+
407
+ ref_defs = {}
408
+ for match in MD_REF_DEF_RE.finditer(markdown):
409
+ ref_defs[match.group(1)] = match.group(2)
410
+
411
+ def resolve(url, alt_for_log):
412
+ if url in cache:
413
+ return cache[url]
414
+ try:
415
+ written = materialize_image(url, next_dest(), token)
416
+ except click.ClickException as exc:
417
+ warning(str(exc))
418
+ cache[url] = None
419
+ return None
420
+ if written is None:
421
+ warning(f"could not parse data URI for image '{alt_for_log}'")
422
+ cache[url] = None
423
+ return None
424
+ rel = f"images/{written.name}"
425
+ cache[url] = rel
426
+ info(f"Saved image [accent]{rel}[/accent]")
427
+ return rel
428
+
429
+ def inline_repl(match):
430
+ alt = match.group(1)
431
+ url = match.group(2)
432
+ rel = resolve(url, alt)
433
+ return f"![{alt}]({rel})" if rel else match.group(0)
434
+
435
+ def ref_repl(match):
436
+ alt = match.group(1)
437
+ ref_id = match.group(2)
438
+ url = ref_defs.get(ref_id)
439
+ if not url:
440
+ return match.group(0)
441
+ rel = resolve(url, alt)
442
+ return f"![{alt}]({rel})" if rel else f"![{alt}]({url})"
443
+
444
+ markdown = MD_INLINE_IMAGE_RE.sub(inline_repl, markdown)
445
+ markdown = MD_REF_IMAGE_RE.sub(ref_repl, markdown)
446
+ markdown = MD_REF_DEF_RE.sub("", markdown)
447
+ markdown = re.sub(r"\n{3,}", "\n\n", markdown).rstrip() + "\n"
448
+ return markdown
449
+
450
+
451
+ def run_doc_read(target, out_dir, no_images, raw, via_docs_api):
452
+ doc_id = extract_doc_id(target)
453
+ if not doc_id:
454
+ raise click.ClickException("could not extract a Google Doc ID from input.")
455
+ info(f"Document ID: [accent]{doc_id}[/accent]")
456
+
457
+ token = access_token()
458
+
459
+ if via_docs_api:
460
+ step("Render via Docs API")
461
+ document = fetch_document(doc_id, token)
462
+ title = document.get("title") or doc_id
463
+ target_dir = Path(out_dir) if out_dir else Path.cwd() / safe_filename(title)
464
+ target_dir.mkdir(parents=True, exist_ok=True)
465
+ info(f"Title: [accent]{title}[/accent]")
466
+ info(f"Output: [accent]{target_dir}[/accent]")
467
+ if raw:
468
+ (target_dir / "raw.json").write_text(
469
+ json.dumps(document, indent=2, ensure_ascii=False), encoding="utf-8"
470
+ )
471
+ info(f"Wrote raw API response to [accent]{target_dir / 'raw.json'}[/accent]")
472
+ resolver = build_image_resolver(document.get("inlineObjects") or {}, target_dir, no_images)
473
+ markdown = document_to_markdown(document, resolver)
474
+ else:
475
+ step("Export via Drive API (text/markdown)")
476
+ markdown, exported_name = drive_export_markdown(doc_id, token)
477
+ title = re.sub(r"\.(md|markdown)$", "", exported_name or "") or doc_id
478
+ target_dir = Path(out_dir) if out_dir else Path.cwd() / safe_filename(title)
479
+ target_dir.mkdir(parents=True, exist_ok=True)
480
+ info(f"Title: [accent]{title}[/accent]")
481
+ info(f"Output: [accent]{target_dir}[/accent]")
482
+ if raw:
483
+ (target_dir / "raw.md").write_text(markdown, encoding="utf-8")
484
+ info(f"Wrote raw export to [accent]{target_dir / 'raw.md'}[/accent]")
485
+ if not no_images:
486
+ step("Download referenced images")
487
+ markdown = download_markdown_images(markdown, target_dir, token)
488
+
489
+ md_path = target_dir / "doc.md"
490
+ md_path.write_text(markdown, encoding="utf-8")
491
+ success(f"Wrote [accent]{md_path}[/accent] ({len(markdown)} bytes)")
492
+
493
+
494
+ @click.group("gdrive")
495
+ def gdrive():
496
+ """**Google Drive** helpers. Read Google Docs (text + images) from URL or ID."""
497
+
498
+
499
+ @gdrive.command("doc-read", epilog=EPILOG)
500
+ @click.argument("target")
501
+ @click.option("-o", "--output", "out_dir", default=None, help="Output directory (default: ./<doc-title>).")
502
+ @click.option("--no-images", is_flag=True, help="Skip image download; keep remote URLs in markdown.")
503
+ @click.option(
504
+ "--via-docs-api",
505
+ is_flag=True,
506
+ help="Use Docs API instead of Drive export (needs Docs API enabled in the OAuth project).",
507
+ )
508
+ @click.option("--raw", is_flag=True, help="Also write the raw API response next to doc.md.")
509
+ def doc_read(target, out_dir, no_images, raw, via_docs_api):
510
+ """Read a Google Doc and write **doc.md** + downloaded **images/**.
511
+
512
+ `TARGET` is either a Google Docs URL (`https://docs.google.com/document/d/<id>/...`)
513
+ or a raw document ID. Auth uses the Drive OAuth token saved in
514
+ `~/.omelet.json` under `rclone.token`, refreshing it automatically when expired.
515
+
516
+ Default path: Drive API export to `text/markdown`, then any inline image URLs
517
+ in the markdown are downloaded into `images/` and their references rewritten
518
+ to local paths. Pass `--via-docs-api` to use the Docs API instead (gives
519
+ cleaner image references but needs Docs API enabled in the OAuth project).
520
+ """
521
+ step("evo gdrive doc-read")
522
+ try:
523
+ run_doc_read(target, out_dir, no_images, raw, via_docs_api)
524
+ except click.ClickException:
525
+ raise
526
+ except Exception as exc:
527
+ error(str(exc))
528
+ sys.exit(1)
@@ -0,0 +1,289 @@
1
+ import json
2
+ import os
3
+ import re
4
+ import sys
5
+ import time
6
+ import urllib.error
7
+ import urllib.parse
8
+ import urllib.request
9
+
10
+ import rich_click as click
11
+ from rich.text import Text
12
+
13
+ from evo_cli.console import console, error, info, step, success, warning
14
+
15
+ BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
16
+ GATE_HOSTS = {"site2s.com", "98sub.net", "traffic2s.com", "seo2s.com"}
17
+ GOOGLE_REFERER = "https://www.google.com/"
18
+ CAPTCHA_URL = "https://98sub.net/site2s/captcha.php"
19
+ GETLINK_URL = "https://site2s.com/rest/connect"
20
+ USER_AGENT = (
21
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
22
+ )
23
+ ENCODED_RE = re.compile(r'encodedRedirectUrl\s*=\s*"([A-Z2-7=]+)"')
24
+ TOKEN_RE = re.compile(r'token\s*=\s*"([0-9a-fA-F]{16,})"')
25
+
26
+ EPILOG = Text.from_markup(
27
+ "[bold]Examples[/bold]\n\n"
28
+ " [cyan]evo site2s https://site2s.com/b8ijsi3b[/cyan] resolve the final link\n"
29
+ " [cyan]evo site2s b8ijsi3b[/cyan] alias also works\n"
30
+ " [cyan]evo site2s <url> --json[/cyan] print machine-readable result\n"
31
+ " [cyan]evo site2s <url> --manual[/cyan] solve the reCAPTCHA yourself in the window\n"
32
+ " [cyan]evo site2s <url> --headless[/cyan] no window (use under xvfb-run)\n\n"
33
+ "[dim]Layer 1 (Base32 redirect) is decoded with no captcha. Task/campaign links\n"
34
+ "go through the 98sub reCAPTCHA + server countdown before the link is released.[/dim]"
35
+ )
36
+
37
+
38
+ def base32_decode(encoded):
39
+ bits = ""
40
+ for ch in encoded:
41
+ if ch == "=":
42
+ break
43
+ val = BASE32_ALPHABET.find(ch.upper())
44
+ if val == -1:
45
+ raise ValueError(f"invalid base32 char: {ch!r}")
46
+ bits += format(val, "05b")
47
+ out = []
48
+ i = 0
49
+ while i < len(bits) - 7:
50
+ out.append(chr(int(bits[i : i + 8], 2)))
51
+ i += 8
52
+ return "".join(out)
53
+
54
+
55
+ def normalize_url(target):
56
+ target = target.strip()
57
+ if re.fullmatch(r"[A-Za-z0-9_-]+", target):
58
+ return f"https://site2s.com/{target}", target
59
+ if not target.startswith("http"):
60
+ target = "https://" + target
61
+ alias = urllib.parse.urlparse(target).path.strip("/").split("/")[-1]
62
+ return target, alias
63
+
64
+
65
+ def host_of(url):
66
+ return urllib.parse.urlparse(url).netloc.lower().split(":")[0]
67
+
68
+
69
+ def is_gate(url):
70
+ host = host_of(url)
71
+ return any(host == g or host.endswith("." + g) for g in GATE_HOSTS)
72
+
73
+
74
+ def load_browser():
75
+ try:
76
+ from patchright.sync_api import sync_playwright
77
+ except ImportError as exc:
78
+ raise click.ClickException(
79
+ "patchright is required for Cloudflare/reCAPTCHA handling.\n"
80
+ " Install it with: pip install patchright\n"
81
+ " It drives the locally installed Google Chrome."
82
+ ) from exc
83
+ return sync_playwright
84
+
85
+
86
+ def new_context(sync_playwright, headless):
87
+ p = sync_playwright().start()
88
+ args = ["--no-sandbox", "--disable-blink-features=AutomationControlled"]
89
+ ctx = p.chromium.launch_persistent_context(
90
+ user_data_dir="/tmp/.evo-site2s-profile",
91
+ channel="chrome",
92
+ headless=headless,
93
+ no_viewport=True,
94
+ args=args,
95
+ )
96
+ return p, ctx
97
+
98
+
99
+ def wait_past_cloudflare(page, timeout):
100
+ deadline = time.time() + timeout
101
+ while time.time() < deadline:
102
+ page.wait_for_timeout(1500)
103
+ title = (page.title() or "").lower()
104
+ if "just a moment" not in title and "moment" not in title:
105
+ return True
106
+ return False
107
+
108
+
109
+ def fetch_redirect_url(page, site_url, timeout):
110
+ info(f"Loading [accent]{site_url}[/accent] (passing Cloudflare)...")
111
+ page.goto(site_url, wait_until="domcontentloaded", timeout=60000)
112
+ if not wait_past_cloudflare(page, timeout):
113
+ raise click.ClickException("timed out waiting for Cloudflare challenge to clear")
114
+ html = page.content()
115
+ match = ENCODED_RE.search(html)
116
+ if not match:
117
+ return None
118
+ decoded = base32_decode(match.group(1))
119
+ info(f"Layer-1 redirect: [accent]{decoded}[/accent]")
120
+ return decoded
121
+
122
+
123
+ def get_recaptcha_response(page):
124
+ return page.evaluate(
125
+ """() => {
126
+ const ta = document.querySelector('textarea[name="g-recaptcha-response"]');
127
+ if (ta && ta.value) return ta.value;
128
+ try { return (typeof grecaptcha !== 'undefined') ? grecaptcha.getResponse() : ''; }
129
+ catch (e) { return ''; }
130
+ }"""
131
+ )
132
+
133
+
134
+ def solve_captcha_for_token(ctx, site_url, alias, manual, captcha_wait, timeout):
135
+ page = ctx.new_page()
136
+ captcha_page = CAPTCHA_URL + "?" + urllib.parse.urlencode({"w": site_url, "v": "0"})
137
+ info(f"Opening captcha page for alias [accent]{alias}[/accent]...")
138
+ page.set_extra_http_headers({"referer": GOOGLE_REFERER})
139
+ page.goto(captcha_page, wait_until="domcontentloaded", timeout=60000)
140
+ page.wait_for_timeout(1500)
141
+
142
+ try:
143
+ page.click("#getLinkButton", timeout=5000)
144
+ except Exception:
145
+ pass
146
+ page.wait_for_timeout(1500)
147
+
148
+ if manual:
149
+ wait_for = max(captcha_wait, 240)
150
+ warning(f"Solve the reCAPTCHA in the browser window (auto-detected, up to {wait_for}s)...")
151
+ else:
152
+ wait_for = captcha_wait
153
+ info("Waiting for reCAPTCHA to validate (checkbox auto-pass)...")
154
+
155
+ deadline = time.time() + wait_for
156
+ while time.time() < deadline:
157
+ if get_recaptcha_response(page):
158
+ break
159
+ page.wait_for_timeout(1500)
160
+
161
+ if not get_recaptcha_response(page):
162
+ raise click.ClickException(
163
+ "reCAPTCHA not solved. Re-run with --manual to solve it yourself, "
164
+ "or the IP may be flagged (try a residential IP)."
165
+ )
166
+
167
+ info("reCAPTCHA solved, submitting...")
168
+ try:
169
+ page.click('button[type="submit"][name="submit"]', timeout=5000)
170
+ except Exception:
171
+ page.evaluate("() => { const f = document.querySelector('form'); if (f) f.submit(); }")
172
+ page.wait_for_timeout(3000)
173
+
174
+ token = None
175
+ deadline = time.time() + 20
176
+ while time.time() < deadline:
177
+ match = TOKEN_RE.search(page.content())
178
+ if match:
179
+ token = match.group(1)
180
+ break
181
+ page.wait_for_timeout(1500)
182
+
183
+ page.close()
184
+ if not token:
185
+ raise click.ClickException("captcha accepted but no token was issued (submit may have failed)")
186
+ info(f"Token issued: [accent]{token}[/accent]")
187
+ return token
188
+
189
+
190
+ def call_getlink(token):
191
+ url = GETLINK_URL + "?" + urllib.parse.urlencode({"action": "getLink", "token": token})
192
+ req = urllib.request.Request(url, method="GET")
193
+ req.add_header("User-Agent", USER_AGENT)
194
+ req.add_header("X-Requested-With", "XMLHttpRequest")
195
+ req.add_header("Referer", "https://98sub.net/")
196
+ try:
197
+ with urllib.request.urlopen(req, timeout=30) as resp:
198
+ return json.loads(resp.read().decode("utf-8"))
199
+ except urllib.error.HTTPError as exc:
200
+ body = exc.read().decode("utf-8", "replace")
201
+ try:
202
+ return json.loads(body)
203
+ except ValueError:
204
+ raise click.ClickException(f"getLink HTTP {exc.code}: {body}") from exc
205
+
206
+
207
+ def poll_getlink(token, timeout):
208
+ info("Polling getLink (waiting out the server countdown ~70s)...")
209
+ deadline = time.time() + timeout
210
+ delay = 5
211
+ last = {}
212
+ with console.status("[info]Waiting for the link to be released...[/info]", spinner="dots"):
213
+ while time.time() < deadline:
214
+ data = call_getlink(token)
215
+ last = data
216
+ if data.get("status") == "success" and data.get("urlrespone"):
217
+ return data
218
+ time.sleep(delay)
219
+ raise click.ClickException(
220
+ f"link not released within timeout. Last response: {json.dumps(last, ensure_ascii=False)}"
221
+ )
222
+
223
+
224
+ def run(target, headless, manual, timeout, captcha_wait, as_json):
225
+ site_url, alias = normalize_url(target)
226
+ sync_playwright = load_browser()
227
+
228
+ if not headless and not os.environ.get("DISPLAY"):
229
+ warning("No DISPLAY found. Headful Chrome needs a display; run under `xvfb-run` or pass --headless.")
230
+
231
+ p, ctx = new_context(sync_playwright, headless)
232
+ try:
233
+ page = ctx.pages[0] if ctx.pages else ctx.new_page()
234
+ redirect_url = fetch_redirect_url(page, site_url, timeout)
235
+
236
+ if redirect_url and not is_gate(redirect_url):
237
+ final = redirect_url
238
+ slug = None
239
+ else:
240
+ if redirect_url:
241
+ info("Layer-1 points to a task gate; running captcha + token flow.")
242
+ token = solve_captcha_for_token(ctx, site_url, alias, manual, captcha_wait, timeout)
243
+ data = poll_getlink(token, timeout)
244
+ final = data.get("urlrespone")
245
+ slug = data.get("slug")
246
+ finally:
247
+ try:
248
+ ctx.close()
249
+ finally:
250
+ p.stop()
251
+
252
+ if as_json:
253
+ console.print_json(json.dumps({"alias": alias, "final_url": final, "slug": slug}))
254
+ else:
255
+ success(f"Final link: [accent]{final}[/accent]")
256
+ if slug:
257
+ info(f"slug: {slug}")
258
+ return final
259
+
260
+
261
+ @click.command("site2s", epilog=EPILOG)
262
+ @click.argument("target")
263
+ @click.option("--headless", is_flag=True, help="Run Chrome headless (needs xvfb; Cloudflare may flag it).")
264
+ @click.option("--manual", is_flag=True, help="Pause for you to solve the reCAPTCHA in the window.")
265
+ @click.option("--timeout", default=150, show_default=True, help="Overall timeout per stage (seconds).")
266
+ @click.option("--captcha-wait", default=40, show_default=True, help="Seconds to wait for reCAPTCHA auto-pass.")
267
+ @click.option("--json", "as_json", is_flag=True, help="Print result as JSON.")
268
+ def site2s(target, headless, manual, timeout, captcha_wait, as_json):
269
+ """Resolve a **Site2S** short link to its real destination.
270
+
271
+ `TARGET` is a `https://site2s.com/<alias>` URL or just the `<alias>`.
272
+
273
+ Site2S hides the destination behind Cloudflare, a fake "search this keyword
274
+ on Google" task, a reCAPTCHA, and a server-side countdown. This command
275
+ drives a real Chrome (via patchright) to pass Cloudflare, decodes the
276
+ Base32 layer-1 redirect, spoofs a Google referrer to skip the keyword task,
277
+ waits out the countdown, and pulls the final link from the `getLink` API.
278
+
279
+ Direct links resolve instantly with no captcha. Task/campaign links need the
280
+ reCAPTCHA solved - it auto-passes on clean IPs, otherwise use `--manual`.
281
+ """
282
+ step("evo site2s")
283
+ try:
284
+ run(target, headless, manual, timeout, captcha_wait, as_json)
285
+ except click.ClickException:
286
+ raise
287
+ except Exception as exc:
288
+ error(str(exc))
289
+ sys.exit(1)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.1.11
3
+ Version: 0.2.0
4
4
  Summary: Evolution CLI - a developer toolbox for setting up dev machines
5
5
  Author: maycuatroi
6
6
  Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
@@ -19,7 +19,9 @@ evo_cli.egg-info/top_level.txt
19
19
  evo_cli/commands/__init__.py
20
20
  evo_cli/commands/cloudflare.py
21
21
  evo_cli/commands/fix_claude.py
22
+ evo_cli/commands/gdrive.py
22
23
  evo_cli/commands/miniconda.py
24
+ evo_cli/commands/site2s.py
23
25
  evo_cli/commands/ssh.py
24
26
  tests/__init__.py
25
27
  tests/test_cli.py
@@ -1 +0,0 @@
1
- 0.1.11
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