Skip to content

Commit b377bc9

Browse files
committed
feat: Add onedrive-downloader
1 parent c1db04b commit b377bc9

2 files changed

Lines changed: 199 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
data/
12
debug/
23
suspects.*
34
*.bak

onedrive-downloader/onedrive.py

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
#!/usr/bin/env python
2+
"""
3+
Download all files from a OneDrive personal shared folder (recursively).
4+
5+
No login or API key required. The folder has been migrated to SharePoint
6+
Online, so the old anonymous api.onedrive.com endpoints return 401. Instead we
7+
replicate what the OneDrive web app does:
8+
9+
1. Mint an anonymous "Badger" token from api-badgerp.svc.ms.
10+
2. Resolve the share link to a driveItem, passing `Prefer: autoredeem` so the
11+
anonymous token is granted access to the shared folder.
12+
3. Walk the folder tree via the /children endpoint (paginated).
13+
4. Download every file via its pre-authenticated @content.downloadUrl,
14+
mirroring the folder structure locally.
15+
"""
16+
17+
import argparse
18+
import base64
19+
import json
20+
import sys
21+
import time
22+
import urllib.error
23+
import urllib.parse
24+
import urllib.request
25+
from http import HTTPStatus
26+
from pathlib import Path
27+
28+
OUTPUT_DIR = "./data"
29+
30+
API = "https://my.microsoftpersonalcontent.com/_api/v2.0"
31+
BADGER_TOKEN_URL = "https://api-badgerp.svc.ms/v1.0/token" # noqa: S105 # non-password
32+
BADGER_APP_ID = "00000000-0000-0000-0000-0000481710a4" # OneDrive web app id
33+
34+
MAX_RETRIES = 6
35+
TRANSIENT_STATUS = {429, 500, 502, 503, 504}
36+
37+
# Fields requested per item; @content.downloadUrl is the anonymous download link.
38+
SELECT = "id,name,size,folder,file,@content.downloadUrl"
39+
40+
41+
def _request(url, *, data=None, headers=None, method=None, timeout=60):
42+
req = urllib.request.Request(url, data=data, headers=headers or {}, method=method) # noqa: S310
43+
return urllib.request.urlopen(req, timeout=timeout) # noqa: S310
44+
45+
46+
def _backoff(attempt, error):
47+
"""Sleep before retrying a transient failure, honouring Retry-After."""
48+
delay = min(60, 2**attempt)
49+
if isinstance(error, urllib.error.HTTPError):
50+
retry_after = error.headers.get("Retry-After")
51+
if retry_after and retry_after.isdigit():
52+
delay = max(delay, int(retry_after))
53+
print(f" ...transient {error}; retrying in {delay}s", file=sys.stderr)
54+
time.sleep(delay)
55+
56+
57+
class OneDrive:
58+
"""Anonymous client for a OneDrive shared folder; holds the Badger token across requests."""
59+
60+
def __init__(self):
61+
self.token = None
62+
63+
def mint_token(self):
64+
"""Get a fresh anonymous Badger token."""
65+
resp = _request(
66+
BADGER_TOKEN_URL,
67+
data=json.dumps({"appId": BADGER_APP_ID}).encode(),
68+
headers={"Content-Type": "application/json"},
69+
method="POST",
70+
)
71+
self.token = json.loads(resp.read())["token"]
72+
return self.token
73+
74+
def api_get(self, url, *, data=None, method=None, prefer=None):
75+
"""GET/POST an API endpoint, re-minting on 401 and backing off on throttling."""
76+
for attempt in range(MAX_RETRIES):
77+
headers = {
78+
"Authorization": "Badger " + self.token,
79+
"Accept": "application/json",
80+
"Referer": "https://onedrive.live.com/",
81+
}
82+
if prefer:
83+
headers["Prefer"] = prefer
84+
try:
85+
resp = _request(url, data=data, headers=headers, method=method)
86+
return json.loads(resp.read())
87+
except urllib.error.HTTPError as e:
88+
if e.code == HTTPStatus.UNAUTHORIZED:
89+
self.mint_token()
90+
continue
91+
if e.code in TRANSIENT_STATUS and attempt < MAX_RETRIES - 1:
92+
_backoff(attempt, e)
93+
continue
94+
raise
95+
except OSError as e:
96+
if attempt < MAX_RETRIES - 1:
97+
_backoff(attempt, e)
98+
continue
99+
raise
100+
return None
101+
102+
def list_children(self, drive_id, item_id):
103+
"""Yield all child items of a folder, following pagination."""
104+
select = urllib.parse.quote(SELECT, safe=",@")
105+
url = f"{API}/drives/{drive_id}/items/{item_id}/children?$top=200&$select={select}"
106+
while url:
107+
page = self.api_get(url)
108+
yield from page.get("value", [])
109+
url = page.get("@odata.nextLink")
110+
111+
def walk_and_download(self, drive_id, item_id, rel_dir, stats, skip_existing, output_dir):
112+
for item in self.list_children(drive_id, item_id):
113+
name = item["name"]
114+
if "folder" in item:
115+
self.walk_and_download(
116+
drive_id,
117+
item["id"],
118+
rel_dir / name,
119+
stats,
120+
skip_existing,
121+
output_dir,
122+
)
123+
continue
124+
dest = output_dir / rel_dir / name
125+
stats["found"] += 1
126+
if skip_existing and dest.exists() and dest.stat().st_size == item.get("size"):
127+
print(f" skip (exists): {rel_dir / name}")
128+
stats["skipped"] += 1
129+
continue
130+
url = item.get("@content.downloadUrl")
131+
if not url:
132+
print(f" WARN no downloadUrl: {rel_dir / name}")
133+
continue
134+
print(f" -> {rel_dir / name} ({item.get('size', 0):,} bytes)")
135+
dest.parent.mkdir(parents=True, exist_ok=True)
136+
tmp = dest.with_name(dest.name + ".part")
137+
for attempt in range(MAX_RETRIES):
138+
try:
139+
with _request(url, headers={"Referer": "https://onedrive.live.com/"}) as resp, tmp.open("wb") as f:
140+
while True:
141+
chunk = resp.read(1 << 16)
142+
if not chunk:
143+
break
144+
f.write(chunk)
145+
tmp.replace(dest)
146+
break
147+
except urllib.error.HTTPError as e:
148+
if e.code in TRANSIENT_STATUS and attempt < MAX_RETRIES - 1:
149+
_backoff(attempt, e)
150+
continue
151+
raise
152+
except OSError as e:
153+
if attempt < MAX_RETRIES - 1:
154+
_backoff(attempt, e)
155+
continue
156+
raise
157+
stats["downloaded"] += 1
158+
159+
160+
def main():
161+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
162+
parser.add_argument("share_url", help="a OneDrive personal share link (1drv.ms/...)")
163+
parser.add_argument("--output", default=OUTPUT_DIR)
164+
parser.add_argument("--no-skip", action="store_true", help="re-download existing files")
165+
args = parser.parse_args()
166+
167+
output_dir = Path(args.output)
168+
output_dir.mkdir(parents=True, exist_ok=True)
169+
170+
client = OneDrive()
171+
print("Minting anonymous token...")
172+
client.mint_token()
173+
print("Resolving share link...")
174+
encoded = base64.b64encode(args.share_url.encode()).decode().rstrip("=")
175+
share_id = "u!" + encoded.replace("+", "-").replace("/", "_")
176+
endpoint = f"{API}/shares/{share_id}/driveitem?$select=id,name,parentReference"
177+
item = client.api_get(endpoint, data=b"", method="POST", prefer="autoredeem")
178+
drive_id, item_id = item["parentReference"]["driveId"], item["id"]
179+
print(f"Root folder: {item.get('name')!r} (drive {drive_id}, item {item_id})")
180+
181+
stats = {"found": 0, "downloaded": 0, "skipped": 0}
182+
try:
183+
client.walk_and_download(
184+
drive_id,
185+
item_id,
186+
Path(),
187+
stats,
188+
skip_existing=not args.no_skip,
189+
output_dir=output_dir,
190+
)
191+
except KeyboardInterrupt:
192+
print("\nInterrupted.", file=sys.stderr)
193+
194+
print(f"\nDone. {stats['found']} file(s) found, {stats['downloaded']} downloaded, {stats['skipped']} skipped.")
195+
196+
197+
if __name__ == "__main__":
198+
main()

0 commit comments

Comments
 (0)