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.
- {evo_cli-0.1.11 → evo_cli-0.2.0}/PKG-INFO +1 -1
- evo_cli-0.2.0/evo_cli/VERSION +1 -0
- {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli/cli.py +4 -0
- evo_cli-0.2.0/evo_cli/commands/gdrive.py +528 -0
- evo_cli-0.2.0/evo_cli/commands/site2s.py +289 -0
- {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli.egg-info/PKG-INFO +1 -1
- {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli.egg-info/SOURCES.txt +2 -0
- evo_cli-0.1.11/evo_cli/VERSION +0 -1
- {evo_cli-0.1.11 → evo_cli-0.2.0}/Containerfile +0 -0
- {evo_cli-0.1.11 → evo_cli-0.2.0}/HISTORY.md +0 -0
- {evo_cli-0.1.11 → evo_cli-0.2.0}/LICENSE +0 -0
- {evo_cli-0.1.11 → evo_cli-0.2.0}/MANIFEST.in +0 -0
- {evo_cli-0.1.11 → evo_cli-0.2.0}/README.md +0 -0
- {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli/__init__.py +0 -0
- {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli/__main__.py +0 -0
- {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli/base.py +0 -0
- {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli/commands/__init__.py +0 -0
- {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli/commands/cloudflare.py +0 -0
- {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli/commands/fix_claude.py +0 -0
- {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli/commands/miniconda.py +0 -0
- {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli/commands/ssh.py +0 -0
- {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli/console.py +0 -0
- {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli.egg-info/dependency_links.txt +0 -0
- {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli.egg-info/entry_points.txt +0 -0
- {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli.egg-info/requires.txt +0 -0
- {evo_cli-0.1.11 → evo_cli-0.2.0}/evo_cli.egg-info/top_level.txt +0 -0
- {evo_cli-0.1.11 → evo_cli-0.2.0}/pyproject.toml +0 -0
- {evo_cli-0.1.11 → evo_cli-0.2.0}/setup.cfg +0 -0
- {evo_cli-0.1.11 → evo_cli-0.2.0}/tests/__init__.py +0 -0
- {evo_cli-0.1.11 → evo_cli-0.2.0}/tests/test_cli.py +0 -0
- {evo_cli-0.1.11 → evo_cli-0.2.0}/tests/test_fix_claude.py +0 -0
|
@@ -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"")
|
|
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"" 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"" if rel else f""
|
|
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)
|
|
@@ -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
|
evo_cli-0.1.11/evo_cli/VERSION
DELETED
|
@@ -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
|
|
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
|