Última actividad 10 hours ago

Export DeepSeek shared conversation to Markdown

Revisión 4215aa9afc85ec3743a5c54192af67ce645a16a8

_deepseek_export.py Sin formato
1#!/usr/bin/env python3
2"""
3Export DeepSeek shared conversation to Markdown.
4
5Requires uv — the fast Python package installer.
6Install: curl -LsSf https://astral.sh/uv/install.sh | sh
7
8Usage (just run it — venv is created automatically):
9 python deepseek_export.py <share_url_or_id> [-o output.md] [--json] [--txt] [--token TOKEN]
10
11Examples:
12 python deepseek_export.py https://chat.deepseek.com/share/nvy7v2ps1r6e2wyoyj
13 python deepseek_export.py nvy7v2ps1r6e2wyoyj -o my_chat.md
14 python deepseek_export.py nvy7v2ps1r6e2wyoyj --json -o chat.json
15 python deepseek_export.py nvy7v2ps1r6e2wyoyj --txt
16
17First run will create .venv/ and install requests via uv.
18"""
19
20# ── auto-venv bootstrap ─────────────────────────────────────────────────────
21import os
22import shutil
23import subprocess
24import sys
25from pathlib import Path
26
27_SCRIPT_DIR = Path(__file__).resolve().parent
28_VENV_DIR = _SCRIPT_DIR / ".venv"
29_VENV_PYTHON = _VENV_DIR / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
30
31_USAGE_TEXT = (
32 "Export DeepSeek shared conversations to Markdown/JSON/text.\n"
33 "Run with -h for full help."
34)
35
36
37def _find_uv() -> str | None:
38 return shutil.which("uv")
39
40
41def _install_uv() -> bool:
42 print("[boot] uv is not installed.", flush=True)
43 answer = input("Install uv now? [Y/n] ").strip().lower()
44 if answer and answer != "y":
45 print("Aborted. Install uv manually: curl -LsSf https://astral.sh/uv/install.sh | sh")
46 return False
47 print("[boot] Installing uv …", flush=True)
48 try:
49 subprocess.check_call(
50 ["curl", "-LsSf", "https://astral.sh/uv/install.sh", "|", "sh"],
51 cwd=_SCRIPT_DIR,
52 )
53 except Exception:
54 # curl ... | sh doesn't work via subprocess.check_call, use shell
55 subprocess.check_call("curl -LsSf https://astral.sh/uv/install.sh | sh", shell=True)
56 if not _find_uv():
57 print("[boot] uv installation failed. Install manually: curl -LsSf https://astral.sh/uv/install.sh | sh")
58 return False
59 return True
60
61
62def _ensure_venv():
63 """Create venv + install deps if missing, then re-exec inside it."""
64 if sys.prefix != sys.base_prefix:
65 return # already inside a venv
66
67 print(_USAGE_TEXT, flush=True)
68 print(flush=True)
69
70 uv = _find_uv()
71 if not uv:
72 if not _install_uv():
73 sys.exit(1)
74 uv = _find_uv()
75 if not uv:
76 sys.exit(1)
77
78 if not _VENV_PYTHON.exists():
79 print("[boot] Creating .venv …", flush=True)
80 subprocess.check_call([uv, "venv", str(_VENV_DIR)], cwd=_SCRIPT_DIR)
81 print("[boot] Installing dependencies …", flush=True)
82 subprocess.check_call(
83 [uv, "pip", "install", "--python", str(_VENV_PYTHON), "requests"],
84 cwd=_SCRIPT_DIR,
85 )
86
87 os.execv(str(_VENV_PYTHON), [str(_VENV_PYTHON), *sys.argv])
88
89
90_ensure_venv()
91# ── end bootstrap ────────────────────────────────────────────────────────────
92
93import argparse
94import json
95import re
96from datetime import datetime, timezone
97
98import requests
99
100# ---------------------------------------------------------------------------
101# Helpers
102# ---------------------------------------------------------------------------
103
104SHARE_URL_RE = re.compile(
105 r"(?:https?://chat\.deepseek\.com)?/share/(?P<id>[A-Za-z0-9_-]+)"
106)
107
108
109def extract_share_id(raw: str) -> str:
110 m = SHARE_URL_RE.search(raw)
111 if m:
112 return m.group("id")
113 if re.fullmatch(r"[A-Za-z0-9_-]{6,}", raw):
114 return raw
115 raise ValueError(f"Cannot extract share id from: {raw}")
116
117
118HEADERS = {
119 "accept": "application/json, text/plain, */*",
120 "accept-language": "en-US,en;q=0.9",
121 "referer": "https://chat.deepseek.com/",
122 "user-agent": (
123 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
124 "(KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"
125 ),
126}
127
128
129# ---------------------------------------------------------------------------
130# Strategy 1 — plain requests (works when WAF is not aggressive)
131# ---------------------------------------------------------------------------
132
133def fetch_via_requests(share_id: str, token: str | None = None) -> dict:
134 s = requests.Session()
135 s.headers.update(HEADERS)
136 s.get("https://chat.deepseek.com/", timeout=15)
137
138 if token:
139 s.headers["authorization"] = f"Bearer {token}"
140
141 url = f"https://chat.deepseek.com/api/v0/share/content?share_id={share_id}"
142 r = s.get(url, timeout=15)
143 r.raise_for_status()
144 data = r.json()
145
146 if data.get("code") != 0:
147 raise RuntimeError(f"API error: {data.get('msg', data)}")
148 return data
149
150
151# ---------------------------------------------------------------------------
152# Strategy 2 — Playwright headless browser (bypasses WAF reliably)
153# ---------------------------------------------------------------------------
154
155def fetch_via_playwright(share_id: str, token: str | None = None) -> dict:
156 try:
157 from playwright.sync_api import sync_playwright
158 except ImportError:
159 raise RuntimeError(
160 "playwright not installed. Run:\n"
161 " uv pip install playwright && uv run playwright install chromium"
162 )
163
164 captured = {}
165
166 def on_response(response):
167 if "/api/v0/share/content" in response.url:
168 try:
169 captured["data"] = response.json()
170 except Exception:
171 pass
172
173 with sync_playwright() as p:
174 browser = p.chromium.launch(headless=True)
175 ctx = browser.new_context(
176 user_agent=HEADERS["user-agent"],
177 viewport={"width": 1280, "height": 800},
178 )
179 page = ctx.new_page()
180 page.on("response", on_response)
181
182 if token:
183 ctx.add_cookies([{
184 "name": "ds_session_id",
185 "value": token,
186 "domain": "chat.deepseek.com",
187 "path": "/",
188 }])
189
190 page.goto(
191 f"https://chat.deepseek.com/share/{share_id}",
192 wait_until="networkidle",
193 timeout=30000,
194 )
195 page.wait_for_timeout(3000)
196 browser.close()
197
198 if not captured.get("data"):
199 raise RuntimeError("Playwright could not capture the share API response")
200 data = captured["data"]
201 if data.get("code") != 0:
202 raise RuntimeError(f"API error: {data.get('msg', data)}")
203 return data
204
205
206# ---------------------------------------------------------------------------
207# Main fetch (tries requests first, falls back to playwright)
208# ---------------------------------------------------------------------------
209
210def fetch_share(share_id: str, token: str | None = None) -> dict:
211 try:
212 print("[1/2] Trying direct API…", end=" ", flush=True)
213 data = fetch_via_requests(share_id, token)
214 print("OK")
215 return data
216 except Exception as e:
217 print(f"failed ({e})")
218
219 print("[2/2] Falling back to Playwright…", end=" ", flush=True)
220 try:
221 data = fetch_via_playwright(share_id, token)
222 print("OK")
223 return data
224 except Exception as e:
225 print(f"failed ({e})")
226 raise RuntimeError(
227 "Both strategies failed. If WAF is blocking you, install Playwright:\n"
228 " uv pip install playwright && uv run playwright install chromium\n"
229 "Or pass your token with --token."
230 )
231
232
233# ---------------------------------------------------------------------------
234# Formatting
235# ---------------------------------------------------------------------------
236
237def _ts(ts) -> str:
238 if not ts:
239 return ""
240 try:
241 return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
242 except Exception:
243 return str(ts)
244
245
246def to_markdown(data: dict) -> str:
247 biz = data.get("data", {}).get("biz_data", {})
248 messages = biz.get("messages", []) or biz.get("chat_messages", [])
249
250 title = biz.get("title") or "DeepSeek Conversation"
251 model = biz.get("model_type") or biz.get("model") or ""
252
253 parts = [f"# {title}\n"]
254 if model:
255 parts.append(f"**Model:** {model}")
256 parts.append("")
257
258 if not messages:
259 parts.append("_No messages found._\n")
260 return "\n".join(parts)
261
262 for msg in messages:
263 role = (msg.get("role") or "").upper()
264 content = msg.get("content") or msg.get("message") or ""
265 if not content and not role:
266 continue
267
268 thinking = msg.get("thinking_content") or msg.get("reasoning_content") or ""
269 elapsed = msg.get("thinking_elapsed_secs")
270 search = msg.get("search_results") or []
271
272 if role == "USER":
273 header = "You"
274 elif role in ("ASSISTANT", "AI"):
275 header = "DeepSeek"
276 elif role == "SYSTEM":
277 header = "System"
278 else:
279 header = role.title()
280
281 parts.append(f"## {header}\n")
282
283 if thinking:
284 label = f"Thinking ({elapsed}s)" if elapsed else "Thinking"
285 parts.append(f"<details><summary>{label}</summary>\n\n{thinking}\n\n</details>\n")
286
287 if search:
288 refs = []
289 for item in search:
290 t = item.get("title") or ""
291 u = item.get("url") or ""
292 if u:
293 refs.append(f"- [{t}]({u})" if t else f"- {u}")
294 if refs:
295 parts.append("**Sources:**\n" + "\n".join(refs) + "\n")
296
297 if content:
298 parts.append(f"{content}\n")
299 parts.append("---\n")
300
301 return "\n".join(parts)
302
303
304def to_plain_text(data: dict) -> str:
305 biz = data.get("data", {}).get("biz_data", {})
306 messages = biz.get("messages", []) or biz.get("chat_messages", [])
307 title = biz.get("title") or "DeepSeek Conversation"
308
309 parts = [f"=== {title} ===\n"]
310 for msg in messages:
311 role = (msg.get("role") or "").upper()
312 content = msg.get("content") or msg.get("message") or ""
313 thinking = msg.get("thinking_content") or msg.get("reasoning_content") or ""
314 if role == "USER":
315 parts.append(f"[User]\n{content}\n")
316 elif role in ("ASSISTANT", "AI"):
317 if thinking:
318 parts.append(f"[Thinking]\n{thinking}\n")
319 parts.append(f"[DeepSeek]\n{content}\n")
320 else:
321 parts.append(f"[{role}]\n{content}\n")
322 return "\n".join(parts)
323
324
325# ---------------------------------------------------------------------------
326# CLI
327# ---------------------------------------------------------------------------
328
329class _ArgumentParser(argparse.ArgumentParser):
330 def error(self, message):
331 self.print_usage(sys.stderr)
332 sys.stderr.write("\n")
333 sys.stderr.write(f"{self.prog}: error: {message}\n")
334 sys.exit(2)
335
336
337def main():
338 ap = _ArgumentParser(
339 description="Export a DeepSeek shared conversation",
340 epilog="Example: %(prog)s https://chat.deepseek.com/share/nvy7v2ps1r6e2wyoyj",
341 )
342 ap.add_argument("link", help="Share URL or share ID")
343 ap.add_argument("-o", "--output", help="Output file path (default: auto-named)")
344 ap.add_argument("--json", action="store_true", help="Dump raw JSON instead of Markdown")
345 ap.add_argument("--txt", action="store_true", help="Dump plain text")
346 ap.add_argument("--token", help="Bearer token for auth (optional, needed for private shares)")
347 args = ap.parse_args()
348
349 share_id = extract_share_id(args.link)
350 print(f"Share ID: {share_id}")
351
352 data = fetch_share(share_id, args.token)
353
354 if args.output:
355 out = Path(args.output)
356 elif args.json:
357 out = Path(f"export_{share_id}.json")
358 elif args.txt:
359 out = Path(f"export_{share_id}.txt")
360 else:
361 out = Path(f"export_{share_id}.md")
362
363 if args.json:
364 content = json.dumps(data, indent=2, ensure_ascii=False)
365 elif args.txt:
366 content = to_plain_text(data)
367 else:
368 content = to_markdown(data)
369
370 out.write_text(content, encoding="utf-8")
371 print(f"Saved to {out} ({len(content)} bytes)")
372
373
374if __name__ == "__main__":
375 main()
376