File size: 12,070 Bytes
d368963 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 |
try:
import os
import io
import json
import hashlib
from googleapiclient.discovery import build
from googleapiclient.http import MediaIoBaseDownload
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from tqdm import tqdm
except ImportError as e:
# Se faltarem as bibliotecas necessárias, levantamos Exception
raise Exception(
"Faltam bibliotecas necessárias para o GoogleDriveDownloader. "
"Instale-as com:\n\n"
" pip install google-api-python-client google-auth-httplib2 google-auth-oauthlib tqdm\n\n"
f"Detalhes do erro: {str(e)}"
)
class GoogleDriveDownloader:
"""
Classe para autenticar e baixar arquivos do Google Drive,
preservando a estrutura de pastas e evitando downloads redundantes.
- Nunca abrirá navegador se não encontrar token válido (apenas levanta exceção).
- Pode ler 'credentials.json' e 'token.json' do disco ou das variáveis de ambiente.
"""
SCOPES = ['https://www.googleapis.com/auth/drive.readonly']
def __init__(self, chunksize=100 * 1024 * 1024):
"""
:param chunksize: Tamanho (em bytes) de cada chunk ao baixar arquivos.
Ex.: 100MB = 100 * 1024 * 1024.
"""
self.chunksize = chunksize
self.service = None
def _get_credentials_from_env_or_file(self):
"""
Verifica se existem variáveis de ambiente para 'CREDENTIALS' e 'TOKEN'.
Caso contrário, tenta usar arquivos locais 'credentials.json' e 'token.json'.
Se o token local/ambiente não existir ou for inválido (sem refresh),
levanta exceção (não abrimos navegador neste fluxo).
"""
print("Procurando credentials na variavel de ambiente...")
env_credentials = os.environ.get("CREDENTIALS") # Conteúdo JSON do client secrets
env_token = os.environ.get("TOKEN") # Conteúdo JSON do token
creds = None
# 1) Carregar credenciais do ambiente, se houver
if env_credentials:
try:
creds_json = json.loads(env_credentials)
except json.JSONDecodeError:
raise ValueError("A variável de ambiente 'CREDENTIALS' não contém JSON válido.")
# Validamos o "client_id" para garantir que seja um JSON de credenciais mesmo
client_id = (
creds_json.get("installed", {}).get("client_id") or
creds_json.get("web", {}).get("client_id")
)
if not client_id:
raise ValueError("Credenciais em memória não parecem válidas. Faltam campos 'client_id'.")
else:
# Se não há credenciais no ambiente, tentamos local
if not os.path.exists("credentials.json"):
raise FileNotFoundError(
"Nenhuma credencial encontrada em ambiente ou no arquivo 'credentials.json'."
)
print("Variavel não encontrada, usando credentials.json")
with open("credentials.json", 'r', encoding='utf-8') as f:
creds_json = json.load(f)
print("Procurando tokens na variavel de ambiente...")
token_data = None
if env_token:
try:
token_data = json.loads(env_token)
except json.JSONDecodeError:
raise ValueError("A variável de ambiente 'TOKEN' não contém JSON válido.")
else:
# Se não há token no ambiente, checamos arquivo local
if os.path.exists("token.json"):
print("Variavel não encontrada, usando token.json")
with open("token.json", 'r', encoding='utf-8') as tf:
token_data = json.load(tf)
else:
raise FileNotFoundError(
"Não há token no ambiente nem em 'token.json'. "
"Não é possível autenticar sem abrir navegador, então abortando."
)
# 3) Criar credenciais a partir do token_data
creds = Credentials.from_authorized_user_info(token_data, self.SCOPES)
# 4) Se expirou, tenta refresh
if not creds.valid:
if creds.expired and creds.refresh_token:
creds.refresh(Request())
# Salva token atualizado, se estiver usando arquivo local
if not env_token: # só sobrescreve se está lendo do disco
with open("token.json", 'w', encoding='utf-8') as token_file:
token_file.write(creds.to_json())
else:
# Se não é válido e não há refresh token, não temos como renovar sem navegador
raise RuntimeError(
"As credenciais de token são inválidas/expiradas e sem refresh token. "
"Não é possível abrir navegador neste fluxo, abortando."
)
return creds
def authenticate(self):
"""Cria e armazena o serviço do Drive API nesta instância."""
creds = self._get_credentials_from_env_or_file()
self.service = build("drive", "v3", credentials=creds)
def _list_files_in_folder(self, folder_id):
"""Retorna a lista de itens (arquivos/pastas) diretamente em 'folder_id'."""
items = []
page_token = None
query = f"'{folder_id}' in parents and trashed=false"
while True:
response = self.service.files().list(
q=query,
spaces='drive',
fields='nextPageToken, files(id, name, mimeType)',
pageToken=page_token
).execute()
items.extend(response.get('files', []))
page_token = response.get('nextPageToken', None)
if not page_token:
break
return items
def _get_file_metadata(self, file_id):
"""
Retorna (size, md5Checksum, modifiedTime) de um arquivo no Drive.
Se algum campo não existir, retorna valor padrão.
"""
data = self.service.files().get(
fileId=file_id,
fields='size, md5Checksum, modifiedTime'
).execute()
size = int(data.get('size', 0))
md5 = data.get('md5Checksum', '')
modified_time = data.get('modifiedTime', '')
return size, md5, modified_time
def _get_all_items_recursively(self, folder_id, parent_path=''):
"""
Percorre recursivamente a pasta (folder_id) no Drive,
retornando lista de dicts (id, name, mimeType, path).
"""
results = []
items = self._list_files_in_folder(folder_id)
for item in items:
current_path = os.path.join(parent_path, item['name'])
if item['mimeType'] == 'application/vnd.google-apps.folder':
results.append({
'id': item['id'],
'name': item['name'],
'mimeType': item['mimeType'],
'path': current_path
})
sub = self._get_all_items_recursively(item['id'], current_path)
results.extend(sub)
else:
results.append({
'id': item['id'],
'name': item['name'],
'mimeType': item['mimeType'],
'path': parent_path
})
return results
def _needs_download(self, local_folder, file_info):
"""
Verifica se o arquivo em 'file_info' precisa ser baixado.
- Se não existir localmente, retorna True.
- Se existir, compara tamanho e MD5 (quando disponível).
- Retorna True se for diferente, False se for idêntico.
"""
file_id = file_info['id']
file_name = file_info['name']
rel_path = file_info['path']
drive_size, drive_md5, _ = self._get_file_metadata(file_id)
full_local_path = os.path.join(local_folder, rel_path, file_name)
if not os.path.exists(full_local_path):
return True # Não existe localmente
local_size = os.path.getsize(full_local_path)
if local_size != drive_size:
return True
if drive_md5:
with open(full_local_path, 'rb') as f:
local_md5 = hashlib.md5(f.read()).hexdigest()
if local_md5 != drive_md5:
return True
return False
def _download_single_file(self, file_id, file_name, relative_path, progress_bar):
"""
Faz download de um único arquivo do Drive, atualizando a barra de progresso global.
"""
# Como fizemos 'os.chdir(local_folder)' antes, 'relative_path' pode ser vazio.
# Então concatenamos sem o local_folder:
file_path = os.path.join(relative_path, file_name)
# Se o path do diretório for vazio, cai no '.' para evitar WinError 3
dir_name = os.path.dirname(file_path) or '.'
os.makedirs(dir_name, exist_ok=True)
request = self.service.files().get_media(fileId=file_id)
with io.FileIO(file_path, 'wb') as fh:
downloader = MediaIoBaseDownload(fh, request, chunksize=self.chunksize)
done = False
previous_progress = 0
while not done:
status, done = downloader.next_chunk()
if status:
current_progress = status.resumable_progress
chunk_downloaded = current_progress - previous_progress
previous_progress = current_progress
progress_bar.update(chunk_downloaded)
def download_from_folder(self, drive_folder_id: str, local_folder: str):
"""
Método principal para:
1. Autenticar sem abrir navegador (usa token local/ambiente).
2. Exibir "Iniciando verificação de documentos".
3. Listar recursivamente arquivos da pasta do Drive.
4. Verificar quais precisam de download.
5. Baixar apenas o necessário, com barra de progresso única.
"""
print("Iniciando verificação de documentos")
if not self.service:
self.authenticate()
print("Buscando lista de arquivos no Drive...")
all_items = self._get_all_items_recursively(drive_folder_id)
# Filtra apenas arquivos (exclui subpastas)
all_files = [f for f in all_items if f['mimeType'] != 'application/vnd.google-apps.folder']
print("Verificando quais arquivos precisam ser baixados...")
files_to_download = []
total_size_to_download = 0
for info in all_files:
if self._needs_download(local_folder, info):
drive_size, _, _ = self._get_file_metadata(info['id'])
total_size_to_download += drive_size
files_to_download.append(info)
if not files_to_download:
print("Nenhum arquivo novo ou atualizado. Tudo sincronizado!")
return
print("Calculando total de bytes a serem baixados...")
# Ajusta a pasta local e cria se necessário
os.makedirs(local_folder, exist_ok=True)
# Muda diretório de trabalho para simplificar criação de subpastas
old_cwd = os.getcwd()
os.chdir(local_folder)
# Cria a barra de progresso global
progress_bar = tqdm(
total=total_size_to_download,
unit='B',
unit_scale=True,
desc='Baixando arquivos'
)
# Baixa só o que precisa
for file_info in files_to_download:
self._download_single_file(
file_id=file_info['id'],
file_name=file_info['name'],
relative_path=file_info['path'],
progress_bar=progress_bar
)
progress_bar.close()
os.chdir(old_cwd)
print("Download concluído com sucesso!")
|