Первая публикация
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from icloudpy.services.drive import DriveService # pylint: disable=unused-import
|
||||
from icloudpy.services.work import WorkService # pylint: disable=unused-import
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
"""Drive service."""
|
||||
import io
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from re import search
|
||||
|
||||
from requests import Response
|
||||
from six import PY2
|
||||
|
||||
|
||||
class DriveService:
|
||||
"""The 'Drive' iCloud service."""
|
||||
|
||||
def __init__(self, service_root, document_root, session, params):
|
||||
self._service_root = service_root
|
||||
self._document_root = document_root
|
||||
self.session = session
|
||||
self.params = dict(params)
|
||||
self._root = None
|
||||
|
||||
def _get_token_from_cookie(self):
|
||||
for cookie in self.session.cookies:
|
||||
if cookie.name == "X-APPLE-WEBAUTH-VALIDATE":
|
||||
match = search(r"\bt=([^:]+)", cookie.value)
|
||||
if match is None:
|
||||
raise Exception(f"Can't extract token from {cookie.value}")
|
||||
return {"token": match.group(1)}
|
||||
raise Exception("Token cookie not found")
|
||||
|
||||
def get_node_data(self, drivewsid):
|
||||
"""Returns the node data."""
|
||||
request = self.session.post(
|
||||
self._service_root + "/retrieveItemDetailsInFolders",
|
||||
params=self.params,
|
||||
data=json.dumps(
|
||||
[
|
||||
{
|
||||
"drivewsid": drivewsid,
|
||||
"partialData": False,
|
||||
}
|
||||
]
|
||||
),
|
||||
)
|
||||
if not request.ok:
|
||||
self.session.raise_error(request.status_code, request.reason)
|
||||
return request.json()[0]
|
||||
|
||||
def get_file(self, file_id, zone="com.apple.CloudDocs", **kwargs):
|
||||
"""Returns iCloud Drive file."""
|
||||
file_params = dict(self.params)
|
||||
file_params.update({"document_id": file_id})
|
||||
response = self.session.get(
|
||||
self._document_root + f"/ws/{zone}/download/by_id",
|
||||
params=file_params,
|
||||
)
|
||||
if not response.ok:
|
||||
self.session.raise_error(response.status_code, response.reason)
|
||||
package_token = response.json().get("package_token")
|
||||
data_token = response.json().get("data_token")
|
||||
if data_token and data_token.get("url"):
|
||||
return self.session.get(data_token["url"], params=self.params, **kwargs)
|
||||
elif package_token and package_token.get("url"):
|
||||
return self.session.get(package_token["url"], params=self.params, **kwargs)
|
||||
else:
|
||||
raise KeyError("'data_token' nor 'package_token' found in response.")
|
||||
|
||||
def get_app_data(self):
|
||||
"""Returns the app library (previously ubiquity)."""
|
||||
request = self.session.get(
|
||||
self._service_root + "/retrieveAppLibraries", params=self.params
|
||||
)
|
||||
if not request.ok:
|
||||
self.session.raise_error(request.status_code, request.reason)
|
||||
return request.json()["items"]
|
||||
|
||||
def get_app_node(self, app_id, folder="documents"):
|
||||
"""Returns the node of the app (ubiquity)"""
|
||||
return DriveNode(self, self.get_node_data("FOLDER::" + app_id + "::" + folder))
|
||||
|
||||
def _get_upload_contentws_url(self, file_object, zone="com.apple.CloudDocs"):
|
||||
"""Get the contentWS endpoint URL to add a new file."""
|
||||
content_type = mimetypes.guess_type(file_object.name)[0]
|
||||
if content_type is None:
|
||||
content_type = ""
|
||||
|
||||
# Get filesize from file object
|
||||
orig_pos = file_object.tell()
|
||||
file_object.seek(0, os.SEEK_END)
|
||||
file_size = file_object.tell()
|
||||
file_object.seek(orig_pos, os.SEEK_SET)
|
||||
|
||||
file_params = self.params
|
||||
file_params.update(self._get_token_from_cookie())
|
||||
|
||||
request = self.session.post(
|
||||
self._document_root + f"/ws/{zone}/upload/web",
|
||||
params=file_params,
|
||||
headers={"Content-Type": "text/plain"},
|
||||
data=json.dumps(
|
||||
{
|
||||
"filename": file_object.name,
|
||||
"type": "FILE",
|
||||
"content_type": content_type,
|
||||
"size": file_size,
|
||||
}
|
||||
),
|
||||
)
|
||||
if not request.ok:
|
||||
self.session.raise_error(request.status_code, request.reason)
|
||||
return (request.json()[0]["document_id"], request.json()[0]["url"])
|
||||
|
||||
def _update_contentws(
|
||||
self, folder_id, sf_info, document_id, file_object, zone="com.apple.CloudDocs"
|
||||
):
|
||||
data = {
|
||||
"data": {
|
||||
"signature": sf_info["fileChecksum"],
|
||||
"wrapping_key": sf_info["wrappingKey"],
|
||||
"reference_signature": sf_info["referenceChecksum"],
|
||||
"size": sf_info["size"],
|
||||
},
|
||||
"command": "add_file",
|
||||
"create_short_guid": True,
|
||||
"document_id": document_id,
|
||||
"path": {
|
||||
"starting_document_id": folder_id,
|
||||
"path": os.path.basename(file_object.name),
|
||||
},
|
||||
"allow_conflict": True,
|
||||
"file_flags": {
|
||||
"is_writable": True,
|
||||
"is_executable": False,
|
||||
"is_hidden": False,
|
||||
},
|
||||
"mtime": int(time.time() * 1000),
|
||||
"btime": int(time.time() * 1000),
|
||||
}
|
||||
|
||||
# Add the receipt if we have one. Will be absent for 0-sized files
|
||||
if sf_info.get("receipt"):
|
||||
data["data"].update({"receipt": sf_info["receipt"]})
|
||||
|
||||
request = self.session.post(
|
||||
self._document_root + f"/ws/{zone}/update/documents",
|
||||
params=self.params,
|
||||
headers={"Content-Type": "text/plain"},
|
||||
data=json.dumps(data),
|
||||
)
|
||||
if not request.ok:
|
||||
self.session.raise_error(request.status_code, request.reason)
|
||||
return request.json()
|
||||
|
||||
def send_file(self, folder_id, file_object, zone="com.apple.CloudDocs"):
|
||||
"""Send new file to iCloud Drive."""
|
||||
document_id, content_url = self._get_upload_contentws_url(file_object, zone)
|
||||
|
||||
request = self.session.post(content_url, files={file_object.name: file_object})
|
||||
if not request.ok:
|
||||
self.session.raise_error(request.status_code, request.reason)
|
||||
content_response = request.json()["singleFile"]
|
||||
|
||||
self._update_contentws(
|
||||
folder_id, content_response, document_id, file_object, zone
|
||||
)
|
||||
|
||||
def create_folders(self, parent, name):
|
||||
"""Creates a new iCloud Drive folder"""
|
||||
request = self.session.post(
|
||||
self._service_root + "/createFolders",
|
||||
params=self.params,
|
||||
headers={"Content-Type": "text/plain"},
|
||||
data=json.dumps(
|
||||
{
|
||||
"destinationDrivewsId": parent,
|
||||
"folders": [
|
||||
{
|
||||
"clientId": self.params["clientId"],
|
||||
"name": name,
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
)
|
||||
return request.json()
|
||||
|
||||
def rename_items(self, node_id, etag, name):
|
||||
"""Renames an iCloud Drive node"""
|
||||
request = self.session.post(
|
||||
self._service_root + "/renameItems",
|
||||
params=self.params,
|
||||
data=json.dumps(
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"drivewsid": node_id,
|
||||
"etag": etag,
|
||||
"name": name,
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
)
|
||||
return request.json()
|
||||
|
||||
def move_items_to_trash(self, node_id, etag):
|
||||
"""Moves an iCloud Drive node to the trash bin"""
|
||||
request = self.session.post(
|
||||
self._service_root + "/moveItemsToTrash",
|
||||
params=self.params,
|
||||
data=json.dumps(
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"drivewsid": node_id,
|
||||
"etag": etag,
|
||||
"clientId": self.params["clientId"],
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
)
|
||||
if not request.ok:
|
||||
self.session.raise_error(request.status_code, request.reason)
|
||||
return request.json()
|
||||
|
||||
@property
|
||||
def root(self):
|
||||
"""Returns the root node."""
|
||||
if not self._root:
|
||||
self._root = DriveNode(
|
||||
self, self.get_node_data("FOLDER::com.apple.CloudDocs::root")
|
||||
)
|
||||
return self._root
|
||||
|
||||
def __getattr__(self, attr):
|
||||
return getattr(self.root, attr)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.root[key]
|
||||
|
||||
|
||||
class DriveNode:
|
||||
"""Drive node."""
|
||||
|
||||
def __init__(self, conn, data):
|
||||
self.data = data
|
||||
self.connection = conn
|
||||
self._children = None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Gets the node name."""
|
||||
if "extension" in self.data:
|
||||
return f'{self.data["name"]}.{self.data["extension"]}'
|
||||
return self.data["name"]
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
"""Gets the node type."""
|
||||
node_type = self.data.get("type")
|
||||
return node_type and node_type.lower()
|
||||
|
||||
def get_children(self):
|
||||
"""Gets the node children."""
|
||||
if not self._children:
|
||||
if "items" not in self.data:
|
||||
self.data.update(self.connection.get_node_data(self.data["drivewsid"]))
|
||||
if "items" not in self.data:
|
||||
raise KeyError(f'No items in folder, status: {self.data["status"]}')
|
||||
self._children = [
|
||||
DriveNode(self.connection, item_data)
|
||||
for item_data in self.data["items"]
|
||||
]
|
||||
return self._children
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
"""Gets the node size."""
|
||||
size = self.data.get("size") # Folder does not have size
|
||||
if not size:
|
||||
return None
|
||||
return int(size)
|
||||
|
||||
@property
|
||||
def date_created(self):
|
||||
"""Gets the node created date (in UTC)."""
|
||||
return _date_to_utc(self.data.get("dateCreated"))
|
||||
|
||||
@property
|
||||
def date_changed(self):
|
||||
"""Gets the node changed date (in UTC)."""
|
||||
return _date_to_utc(self.data.get("dateChanged")) # Folder does not have date
|
||||
|
||||
@property
|
||||
def date_modified(self):
|
||||
"""Gets the node modified date (in UTC)."""
|
||||
return _date_to_utc(self.data.get("dateModified")) # Folder does not have date
|
||||
|
||||
@property
|
||||
def date_last_open(self):
|
||||
"""Gets the node last open date (in UTC)."""
|
||||
return _date_to_utc(self.data.get("lastOpenTime")) # Folder does not have date
|
||||
|
||||
def open(self, **kwargs):
|
||||
"""Gets the node file."""
|
||||
# iCloud returns 400 Bad Request for 0-byte files
|
||||
if self.data["size"] == 0:
|
||||
response = Response()
|
||||
response.raw = io.BytesIO()
|
||||
return response
|
||||
return self.connection.get_file(
|
||||
self.data["docwsid"], zone=self.data["zone"], **kwargs
|
||||
)
|
||||
|
||||
def upload(self, file_object, **kwargs):
|
||||
""" "Upload a new file."""
|
||||
return self.connection.send_file(
|
||||
self.data["docwsid"], file_object, zone=self.data["zone"], **kwargs
|
||||
)
|
||||
|
||||
def dir(self):
|
||||
"""Gets the node list of directories."""
|
||||
if self.type == "file":
|
||||
return None
|
||||
return [child.name for child in self.get_children()]
|
||||
|
||||
def mkdir(self, folder):
|
||||
"""Create a new directory directory."""
|
||||
# remove cached entries information first so that it will be re-read on next get_children()
|
||||
self._children = None
|
||||
if "items" in self.data:
|
||||
self.data.pop("items")
|
||||
return self.connection.create_folders(self.data["drivewsid"], folder)
|
||||
|
||||
def rename(self, name):
|
||||
"""Rename an iCloud Drive item."""
|
||||
return self.connection.rename_items(
|
||||
self.data["drivewsid"], self.data["etag"], name
|
||||
)
|
||||
|
||||
def delete(self):
|
||||
"""Delete an iCloud Drive item."""
|
||||
return self.connection.move_items_to_trash(
|
||||
self.data["drivewsid"], self.data["etag"]
|
||||
)
|
||||
|
||||
def get(self, name):
|
||||
"""Gets the node child."""
|
||||
if self.type == "file":
|
||||
return None
|
||||
return [child for child in self.get_children() if child.name == name][0]
|
||||
|
||||
def __getitem__(self, key):
|
||||
try:
|
||||
return self.get(key)
|
||||
except IndexError as error:
|
||||
raise KeyError(f"No child named '{key}' exists") from error
|
||||
|
||||
def __unicode__(self):
|
||||
return f"{{type: {self.type}, name: {self.name}}}"
|
||||
|
||||
def __str__(self):
|
||||
as_unicode = self.__unicode__()
|
||||
if PY2:
|
||||
return as_unicode.encode("utf-8", "ignore")
|
||||
return as_unicode
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{type(self).__name__}: {str(self)}>"
|
||||
|
||||
|
||||
def _date_to_utc(date):
|
||||
if not date:
|
||||
return None
|
||||
# jump through hoops to return time in UTC rather than California time
|
||||
match = search(r"^(.+?)([\+\-]\d+):(\d\d)$", date)
|
||||
if not match:
|
||||
# Already in UTC
|
||||
return datetime.strptime(date, "%Y-%m-%dT%H:%M:%SZ")
|
||||
base = datetime.strptime(match.group(1), "%Y-%m-%dT%H:%M:%S")
|
||||
diff = timedelta(hours=int(match.group(2)), minutes=int(match.group(3)))
|
||||
return base - diff
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Work service."""
|
||||
import io
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import time
|
||||
import shutil
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from re import search
|
||||
|
||||
from requests import Response
|
||||
from six import PY2
|
||||
from src import LOGGER, config_parser
|
||||
|
||||
class WorkService:
|
||||
|
||||
def __init__(self, document_root, session, params, client_id, dsid):
|
||||
|
||||
self._document_root = document_root
|
||||
self.session = session
|
||||
self.params = dict(params)
|
||||
self.client_id = client_id
|
||||
self.dsid = dsid
|
||||
self._root = None
|
||||
|
||||
def export_response(self, document_id, secret, zone):
|
||||
file_params = dict(self.params)
|
||||
file_params.update({"clientBuildNumber": "current"})
|
||||
file_params.update({"clientMasteringNumber": "Mcurrent"})
|
||||
file_params.update({"dsid": self.dsid})
|
||||
request = self.session.post(
|
||||
self._document_root + f"/iw/export-ws/{self.dsid}/export_document",
|
||||
params=file_params,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
data={
|
||||
"primary": "primary",
|
||||
"document_type": "numbers",
|
||||
"format": "org.openxmlformats.spreadsheetml.sheet",
|
||||
"locale": "en",
|
||||
"encrypt_result": "N",
|
||||
"secret": secret,
|
||||
"document_id": document_id,
|
||||
"zone": zone,
|
||||
}
|
||||
,
|
||||
)
|
||||
if not request.ok:
|
||||
self.session.raise_error(request.status_code, request.reason)
|
||||
|
||||
return request.json()["job_id"]
|
||||
|
||||
def check_job(self, job_id):
|
||||
url = self._document_root + f"/iw/export-ws/{self.dsid}/check_export_status"
|
||||
file_params = dict(self.params)
|
||||
file_params.update({"build": "primary"})
|
||||
file_params.update({"clientBuildNumber": "current"})
|
||||
file_params.update({"clientMasteringNumber": "Mcurrent"})
|
||||
file_params.update({"job_id": job_id})
|
||||
file_params.update({"dsid": self.dsid})
|
||||
request = self.session.post(
|
||||
url,
|
||||
params=file_params,
|
||||
headers={"Content-Type": "text/plain"},
|
||||
data={},
|
||||
)
|
||||
if not request.ok:
|
||||
self.session.raise_error(request.status_code, request.reason)
|
||||
if request.json()["job_status"] == "failure":
|
||||
raise Exception(request.json()["job_status"])
|
||||
if request.json()["job_status"] == "success":
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def download_file(self, job_id, destination_path, name):
|
||||
url = self._document_root + f"/iw/export-ws/{self.dsid}/download_exported_document"
|
||||
file_params = dict(self.params)
|
||||
file_params.update({"build": "primary"})
|
||||
file_params.update({"file_name": name + f".xlsx"})
|
||||
file_params.update({"job_id": job_id})
|
||||
local_filename = os.path.join(destination_path, name + f".xlsx")
|
||||
try:
|
||||
response = self.session.get(url, params=file_params, stream=True)
|
||||
with open(local_filename, 'wb') as out_file:
|
||||
shutil.copyfileobj(response.raw, out_file)
|
||||
except self.session.exceptions.RequestException as e:
|
||||
raise Exception(f"Ошибка скачивания сконцентрированного файла {local_filename}: {str(e)}")
|
||||
return local_filename
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user