mirror of
https://github.com/csd4ni3l/gp-dl.git
synced 2026-01-01 12:33:44 +01:00
Compare commits
5 Commits
v0.1.0
...
59d49bc482
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59d49bc482 | ||
|
|
8f0a6909e2 | ||
|
|
a79d3c96da | ||
|
|
d11c67e7f0 | ||
|
|
301956a954 |
47
README.md
47
README.md
@@ -4,38 +4,39 @@
|
|||||||
|
|
||||||
This tool automates the process of downloading photos from Google Photos albums by simulating user interaction with the web interface. It uses Selenium to open shared album links, click the "Download all" button, and extract the images to your local system.
|
This tool automates the process of downloading photos from Google Photos albums by simulating user interaction with the web interface. It uses Selenium to open shared album links, click the "Download all" button, and extract the images to your local system.
|
||||||
|
|
||||||
## ✨ Features
|
## Features
|
||||||
|
|
||||||
* 🔗 Accepts public/shared Google Photos album URLs
|
* Accepts link-shared Google Photos album URLs
|
||||||
* 🖱️ Simulates browser behavior to download photos via the "Download all" option
|
* Accepts your own Google Photos album URLs if you supply the profile directory.
|
||||||
* 🗃️ Automatically extracts downloaded `.zip` files into organized folders
|
* Automatically extracts downloaded `.zip` files into organized folders
|
||||||
* 🛠️ Works without needing any API keys or OAuth setup
|
* Works without needing any API keys or OAuth setup
|
||||||
* 📂 Supports batch downloading of multiple album links
|
* Supports batch downloading of multiple album links
|
||||||
|
|
||||||
## 🛑 Why not use the Google Photos API?
|
## Why not use the Google Photos API?
|
||||||
|
|
||||||
As of recent updates, **the original Google Photos API is deprecated**. While the **Google Picker API** is still available, it comes with several major limitations:
|
**The original Google Photos API is deprecated**. While the **Google Picker API** is still available, it comes with several major limitations:
|
||||||
|
|
||||||
* 🚫 You must select each photo manually — no "select all" option
|
* You must select each photo manually, no "select all" option, meaning it can not be automated.
|
||||||
* 📉 Limited to a maximum number of items (up to 100 photos per interaction)
|
* Limited to a maximum number of items
|
||||||
* 🔐 Requires setting up a Google Cloud project and API credentials
|
* It requires setting up a Google Cloud project and API credentials, which is pretty hard.
|
||||||
|
|
||||||
Due to these restrictions, this Selenium-based solution is one of the few remaining ways to fully automate bulk downloads from Google Photos albums.
|
## Disclaimer
|
||||||
|
|
||||||
## ⚠️ Disclaimer
|
|
||||||
|
|
||||||
* The project was not made by AI, just the README.
|
|
||||||
* It automates actions that a human user would normally perform in a browser.
|
|
||||||
* Be aware of Google’s Terms of Service before using this tool.
|
* Be aware of Google’s Terms of Service before using this tool.
|
||||||
|
* It simulates human actions, but Google might not be happy about someone using this.
|
||||||
|
* Selenium auto-downloads the Chrome driver if not found, which can take up space.
|
||||||
|
|
||||||
## 🧰 Requirements
|
## Installation
|
||||||
|
|
||||||
* Python 3.11+
|
`pip install gp-dl`
|
||||||
* Selenium
|
|
||||||
* Chrome or Chromium + WebDriver
|
|
||||||
|
|
||||||
## 💡 Usage
|
## Usage
|
||||||
|
|
||||||
```bash
|
### CLI
|
||||||
python main.py --album-urls YOUR_ALBUM_LINK_HERE --output-dir test_images
|
`gp-dl --album-urls ALBUM_URL ALBUM_URL2 --output-dir test`
|
||||||
|
|
||||||
|
### As a module
|
||||||
|
```py
|
||||||
|
from gp_dl import download_albums
|
||||||
|
successful_albums, failed_albums, album_times = download_albums(["ALBUM_URL", "ALBUM_URL2"], output_dir="test")
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
__version__ = "0.1.0"
|
from .lib import download_albums
|
||||||
|
|
||||||
|
__version__ = "0.3.0"
|
||||||
|
|||||||
71
gp_dl/cli.py
Normal file
71
gp_dl/cli.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import argparse, logging, sys, time
|
||||||
|
from statistics import median
|
||||||
|
from .lib import download_albums, login, list_albums
|
||||||
|
|
||||||
|
BANNER = """
|
||||||
|
██████ ██████ ██████ ██
|
||||||
|
██ ██ ██ ██ ██ ██
|
||||||
|
██ ███ ██████ █████ ██ ██ ██
|
||||||
|
██ ██ ██ ██ ██ ██
|
||||||
|
██████ ██ ██████ ███████
|
||||||
|
|
||||||
|
gp-dl — Google Photos Downloader
|
||||||
|
Download full-resolution albums from Google Photos using Selenium
|
||||||
|
|
||||||
|
Author: csd4ni3l | GitHub: https://github.com/csd4ni3l
|
||||||
|
"""
|
||||||
|
|
||||||
|
LOG_LEVELS = {
|
||||||
|
"DEBUG": logging.DEBUG,
|
||||||
|
"INFO": logging.INFO,
|
||||||
|
"ERROR": logging.ERROR,
|
||||||
|
"FATAL": logging.FATAL,
|
||||||
|
"QUIET": 999999999
|
||||||
|
}
|
||||||
|
|
||||||
|
def parse_cli_args():
|
||||||
|
parser = argparse.ArgumentParser(description="Download full-res images from a Google Photos album using Selenium.")
|
||||||
|
parser.add_argument("--album-urls", nargs="+", help="Google Photos album URL(s)")
|
||||||
|
parser.add_argument("--output-dir", default=None, required=True, help="The directory to save downloaded albums")
|
||||||
|
parser.add_argument("--driver-path", default=None, help="Custom Chrome driver path")
|
||||||
|
parser.add_argument("--profile-dir", default=None, help="A Chrome user data directory for sessions, set this if you want to open non-shared links.")
|
||||||
|
parser.add_argument("--headless", action="store_true", help="Run Chrome headlessly")
|
||||||
|
parser.add_argument("--user", default=None, help="Google user login (ie. email address)")
|
||||||
|
parser.add_argument("--password", default=None, help="Google user password")
|
||||||
|
parser.add_argument("--log-level", default="INFO", help="Specifies what to include in log output. Available levels: debug, info, error, fatal")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
def configure_logging(log_level: str):
|
||||||
|
if not log_level.upper() in LOG_LEVELS:
|
||||||
|
print(f"Invalid logging level: {log_level}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=LOG_LEVELS[log_level.upper()])
|
||||||
|
for logger_to_disable in ["selenium", "urllib3"]:
|
||||||
|
logging.getLogger(logger_to_disable).propagate = False
|
||||||
|
logging.getLogger(logger_to_disable).disabled = True
|
||||||
|
|
||||||
|
def run_cli():
|
||||||
|
args = parse_cli_args()
|
||||||
|
|
||||||
|
if not args.log_level.upper() == "QUIET":
|
||||||
|
print(BANNER)
|
||||||
|
|
||||||
|
configure_logging(args.log_level)
|
||||||
|
|
||||||
|
all_start = time.perf_counter()
|
||||||
|
album_urls = args.album_urls
|
||||||
|
if not args.album_urls:
|
||||||
|
album_urls = list_albums(driver_path=args.driver_path, profile_dir=args.profile_dir, headless=args.headless, user=args.user, password=args.password)
|
||||||
|
|
||||||
|
successful_albums, failed_albums, album_times = download_albums(album_urls, args.output_dir, args.driver_path, args.profile_dir, args.headless)
|
||||||
|
|
||||||
|
logging.info("")
|
||||||
|
logging.info("===== DOWNLOAD STATISTICS =====")
|
||||||
|
logging.info(f"Total albums given: {len(album_urls)}")
|
||||||
|
logging.info(f"Successful albums ({len(successful_albums)}): {', '.join(successful_albums) or None}")
|
||||||
|
logging.info(f"Failed albums ({len(failed_albums)}): {', '.join(failed_albums) or 'None'}")
|
||||||
|
logging.info(f"Median time taken per album: {median(album_times or [0]):.2f} seconds")
|
||||||
|
logging.info(f"Average time taken per album: {sum(album_times or [0]) / len(album_times or [0]):.2f} seconds")
|
||||||
|
logging.info(f"Total time taken: {time.perf_counter() - all_start:.2f} seconds")
|
||||||
|
logging.info("================================")
|
||||||
253
gp_dl/lib.py
Normal file
253
gp_dl/lib.py
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
from selenium.webdriver import Chrome, ChromeService
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.chrome.options import Options
|
||||||
|
from selenium.common.exceptions import TimeoutException
|
||||||
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
|
from selenium.webdriver.common.keys import Keys
|
||||||
|
from zipfile import ZipFile
|
||||||
|
import os, time, logging, json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
__driver__ = None
|
||||||
|
WEB_DRIVER_WAIT = int(os.getenv("WEB_DRIVER_WAIT","10"))
|
||||||
|
WSL_INSIDE = os.getenv("WSL_INSIDE", False)
|
||||||
|
CHROME_BINARY = os.getenv("CHROME_BINARY","")
|
||||||
|
GOOGLE_LANG = os.getenv("GOOGLE_LANG","en")
|
||||||
|
|
||||||
|
def load_translation(locale):
|
||||||
|
file_path = Path(__file__).parent / Path("locales") / f"{locale}.json"
|
||||||
|
with open(file_path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
__labels = load_translation(GOOGLE_LANG)
|
||||||
|
|
||||||
|
def get_driver(driver_path=None, profile_dir=None, headless=True):
|
||||||
|
global __driver__
|
||||||
|
if __driver__ is None:
|
||||||
|
logging.info(f"Initialize driver with driver {driver_path} and profile ({profile_dir} (headless={headless}))...")
|
||||||
|
__driver__ = setup_driver(driver_path, profile_dir, headless)
|
||||||
|
return __driver__
|
||||||
|
|
||||||
|
def reset_driver ():
|
||||||
|
global __driver__
|
||||||
|
__driver__ = None
|
||||||
|
|
||||||
|
def setup_driver(driver_path=None, profile_dir=None, headless=True):
|
||||||
|
chrome_options = Options()
|
||||||
|
if CHROME_BINARY:
|
||||||
|
logging.info(f"Use binary <{CHROME_BINARY}>")
|
||||||
|
chrome_options.binary_location = CHROME_BINARY
|
||||||
|
if profile_dir:
|
||||||
|
chrome_options.add_argument(f"--user-data-dir={profile_dir}")
|
||||||
|
if headless:
|
||||||
|
if WSL_INSIDE:
|
||||||
|
chrome_options.add_argument("--headless=new")
|
||||||
|
chrome_options.add_argument("--no-sandbox")
|
||||||
|
else:
|
||||||
|
chrome_options.add_argument("--headless")
|
||||||
|
|
||||||
|
prefs = {
|
||||||
|
"download.prompt_for_download": False,
|
||||||
|
"download.default_directory": os.path.join(os.getcwd(), "gp_temp"),
|
||||||
|
"profile.default_content_setting_values.automatic_downloads": 1
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome_options.add_experimental_option("prefs", prefs)
|
||||||
|
chrome_options.add_argument("--disable-gpu")
|
||||||
|
chrome_options.add_argument("--window-size=1920,1080")
|
||||||
|
|
||||||
|
if driver_path:
|
||||||
|
service = ChromeService(executable_path=driver_path)
|
||||||
|
return Chrome(options=chrome_options, service=service)
|
||||||
|
else:
|
||||||
|
return Chrome(options=chrome_options)
|
||||||
|
|
||||||
|
def find_zip_file():
|
||||||
|
for file in os.listdir("gp_temp"):
|
||||||
|
if file.endswith(".zip"):
|
||||||
|
return file
|
||||||
|
|
||||||
|
def find_crdownload_file():
|
||||||
|
for file in os.listdir("gp_temp"):
|
||||||
|
if file.endswith(".crdownload"):
|
||||||
|
return file
|
||||||
|
|
||||||
|
|
||||||
|
def login(
|
||||||
|
user: str ,
|
||||||
|
password: str,
|
||||||
|
driver_path: str | None = None, headless=True):
|
||||||
|
|
||||||
|
|
||||||
|
driver = get_driver(driver_path=driver_path,headless=headless)
|
||||||
|
driver.get("https://photos.google.com/login")
|
||||||
|
|
||||||
|
usernameFieldPath = "identifierId"
|
||||||
|
usernameNextButtonPath = "identifierNext"
|
||||||
|
passwordFieldPath = "Passwd"
|
||||||
|
passwordNextButtonPath = "passwordNext"
|
||||||
|
|
||||||
|
usernameField = WebDriverWait(driver, WEB_DRIVER_WAIT).until(EC.presence_of_element_located((By.ID, usernameFieldPath)))
|
||||||
|
time.sleep(1)
|
||||||
|
usernameField.send_keys(user)
|
||||||
|
|
||||||
|
usernameNextButton = WebDriverWait(driver, WEB_DRIVER_WAIT).until(EC.presence_of_element_located((By.ID, usernameNextButtonPath)))
|
||||||
|
usernameNextButton.click()
|
||||||
|
|
||||||
|
passwordField = WebDriverWait(driver, WEB_DRIVER_WAIT).until(EC.presence_of_element_located((By.NAME, passwordFieldPath)))
|
||||||
|
time.sleep(1)
|
||||||
|
passwordField.send_keys(password)
|
||||||
|
|
||||||
|
passwordNextButton = WebDriverWait(driver, WEB_DRIVER_WAIT).until(EC.presence_of_element_located((By.ID, passwordNextButtonPath)))
|
||||||
|
passwordNextButton.click()
|
||||||
|
|
||||||
|
|
||||||
|
def list_albums(
|
||||||
|
profile_dir: str | None = None,
|
||||||
|
user: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
driver_path: str | None = None,
|
||||||
|
headless=True):
|
||||||
|
|
||||||
|
driver = get_driver(driver_path=driver_path,headless=headless)
|
||||||
|
if profile_dir is None:
|
||||||
|
if user and password:
|
||||||
|
login(user=user, password=password, driver_path=driver_path, headless=headless)
|
||||||
|
else:
|
||||||
|
logging.fatal("Neither profile_dir nor user and password has been defined, cannot fetch your albums.")
|
||||||
|
return
|
||||||
|
|
||||||
|
driver.get("https://photos.google.com/albums")
|
||||||
|
try:
|
||||||
|
album_div = WebDriverWait(driver, WEB_DRIVER_WAIT).until(EC.presence_of_element_located((By.CSS_SELECTOR, 'div[aria-label="{albums}"'.format_map(__labels))))
|
||||||
|
except TimeoutException:
|
||||||
|
logging.error("Could not find the '{albums}' section in time.".format_map(__labels))
|
||||||
|
logging.error(f"Check if GOOGLE_LANG (value={GOOGLE_LANG}, default en) is set to your language and available.")
|
||||||
|
logging.info("Continuing with next album URL.")
|
||||||
|
failed_albums.append(album_title)
|
||||||
|
raise
|
||||||
|
links = album_div.find_elements(By.TAG_NAME, "a")
|
||||||
|
album_links = [link.get_attribute("href") for link in links]
|
||||||
|
return album_links
|
||||||
|
|
||||||
|
def download_all_albums(
|
||||||
|
profile_dir: str | None = None,
|
||||||
|
user: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
driver_path: str | None = None,
|
||||||
|
headless=True):
|
||||||
|
album_urls = list_albums(driver_path=driver_path, headless=headless)
|
||||||
|
download_albums(album_urls, output_dir, driver_path, profile_dir, headless)
|
||||||
|
|
||||||
|
def download_albums(
|
||||||
|
album_urls: list[str],
|
||||||
|
output_dir: str,
|
||||||
|
driver_path: str | None = None,
|
||||||
|
profile_dir: str | None = None,
|
||||||
|
headless: bool = False,
|
||||||
|
) -> tuple[list[str], list[str], list[float]]:
|
||||||
|
"""
|
||||||
|
1) Download full-resolution images from one or more Google Photos albums using Selenium.
|
||||||
|
|
||||||
|
2) Return lists of successful and failed album names, as well as download durations.
|
||||||
|
|
||||||
|
:type album_urls: list[str]
|
||||||
|
:param album_urls: One or more Google Photos album URLs to download images from.
|
||||||
|
|
||||||
|
:type output_dir: str
|
||||||
|
:param output_dir: Directory path where the downloaded albums will be saved.
|
||||||
|
|
||||||
|
:type driver_path: str | None
|
||||||
|
:param driver_path: Path to a custom Chrome WebDriver binary. If None, Selenium will download it or choose the default system ChromeDriver.
|
||||||
|
|
||||||
|
:type profile_dir: str | None
|
||||||
|
:param profile_dir: Path to a Chrome user data directory. Use this to access private albums (non-shared links).
|
||||||
|
|
||||||
|
:type headless: bool
|
||||||
|
:param headless: Whether to run Chrome in headless mode. Defaults to False.
|
||||||
|
|
||||||
|
:returns: A tuple containing the names of the successful albums, names of the albums that failed to download, and the durations it took to download each album.
|
||||||
|
:rtype: tuple[list[str], list[str], list[float]]
|
||||||
|
"""
|
||||||
|
|
||||||
|
driver = get_driver(driver_path=driver_path, profile_dir=profile_dir, headless=headless)
|
||||||
|
|
||||||
|
if not os.path.exists(output_dir) or not os.path.isdir(output_dir):
|
||||||
|
logging.fatal("Invalid output directory. Please supply a valid and existing directory.")
|
||||||
|
return
|
||||||
|
|
||||||
|
failed_albums = []
|
||||||
|
successful_albums = []
|
||||||
|
album_times = []
|
||||||
|
|
||||||
|
for album_url in album_urls:
|
||||||
|
album_start = time.perf_counter()
|
||||||
|
|
||||||
|
if not os.path.exists("gp_temp") or not os.path.isdir("gp_temp"):
|
||||||
|
logging.info("Creating gp_temp directory to temporarily store the downloaded zip files.")
|
||||||
|
os.makedirs("gp_temp", exist_ok=True)
|
||||||
|
|
||||||
|
driver.get(album_url)
|
||||||
|
|
||||||
|
album_title = driver.title.split(" -")[0]
|
||||||
|
|
||||||
|
logging.info(f"Now downloading {album_title} ({album_url})")
|
||||||
|
|
||||||
|
logging.debug("Waiting for menu button...")
|
||||||
|
try:
|
||||||
|
share_buttons = WebDriverWait(driver, WEB_DRIVER_WAIT).until(EC.element_to_be_clickable((By.XPATH, "//*[@aria-label=\"{share}\"]".format_map(__labels))))
|
||||||
|
|
||||||
|
except TimeoutException:
|
||||||
|
logging.error("Could not find the '{share}' button in time.".format_map(__labels))
|
||||||
|
logging.error(f"Check if GOOGLE_LANG (value={GOOGLE_LANG}, default en) is set to your language and available.")
|
||||||
|
logging.info("Continuing with next album URL.")
|
||||||
|
failed_albums.append(album_title)
|
||||||
|
continue
|
||||||
|
share_buttons.send_keys(Keys.TAB)
|
||||||
|
menu_button = driver.execute_script("return document.activeElement")
|
||||||
|
menu_button.click()
|
||||||
|
|
||||||
|
logging.debug("Waiting for download all button...")
|
||||||
|
try:
|
||||||
|
download_all_button = WebDriverWait(driver, WEB_DRIVER_WAIT).until(EC.presence_of_element_located((By.XPATH, '//*[@aria-label="{download}"]'.format_map(__labels))))
|
||||||
|
except TimeoutException:
|
||||||
|
logging.error("Could not find the '{download}' button in time.".format_map(__labels))
|
||||||
|
logging.info("Continuing with next album.")
|
||||||
|
failed_albums.append(album_title)
|
||||||
|
continue
|
||||||
|
|
||||||
|
logging.debug("Clicking the download all button...")
|
||||||
|
download_all_button.click()
|
||||||
|
|
||||||
|
logging.info("Waiting for Google to prepare the file...")
|
||||||
|
crdownload_file = None
|
||||||
|
while not crdownload_file:
|
||||||
|
crdownload_file = find_crdownload_file()
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
logging.info("Waiting for the download to finish...")
|
||||||
|
zip_file = None
|
||||||
|
while not zip_file:
|
||||||
|
zip_file = find_zip_file()
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
logging.debug(f"Zip file downloaded, extracting to {output_dir}")
|
||||||
|
|
||||||
|
with ZipFile(f"gp_temp/{zip_file}") as opened_file:
|
||||||
|
opened_file.extractall(output_dir)
|
||||||
|
|
||||||
|
logging.debug("Deleting zip file...")
|
||||||
|
os.remove(f"gp_temp/{zip_file}")
|
||||||
|
|
||||||
|
logging.info(f"Succesfully extracted zip file to {output_dir}")
|
||||||
|
|
||||||
|
successful_albums.append(album_title)
|
||||||
|
album_times.append(time.perf_counter() - album_start)
|
||||||
|
|
||||||
|
logging.debug("Removing temporary gp_temp directory.")
|
||||||
|
os.removedirs("gp_temp")
|
||||||
|
|
||||||
|
driver.quit()
|
||||||
|
|
||||||
|
return successful_albums, failed_albums, album_times
|
||||||
6
gp_dl/locales/en.json
Normal file
6
gp_dl/locales/en.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"share": "Share",
|
||||||
|
"albums": "Albums",
|
||||||
|
"download": "Download all",
|
||||||
|
"options": "More options"
|
||||||
|
}
|
||||||
6
gp_dl/locales/fr.json
Normal file
6
gp_dl/locales/fr.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"share": "Partager",
|
||||||
|
"albums": "Albums",
|
||||||
|
"download": "Tout télécharger",
|
||||||
|
"options": "Plus d'options"
|
||||||
|
}
|
||||||
167
gp_dl/main.py
167
gp_dl/main.py
@@ -1,167 +0,0 @@
|
|||||||
import os, time, argparse, re, sys, logging
|
|
||||||
from selenium.webdriver import Chrome, ChromeService
|
|
||||||
from selenium.webdriver.common.by import By
|
|
||||||
from selenium.webdriver.chrome.options import Options
|
|
||||||
from selenium.common.exceptions import TimeoutException
|
|
||||||
from selenium.webdriver.support.ui import WebDriverWait
|
|
||||||
from selenium.webdriver.support import expected_conditions as EC
|
|
||||||
from zipfile import ZipFile
|
|
||||||
|
|
||||||
BANNER = """
|
|
||||||
██████ ██████ ██████ ██
|
|
||||||
██ ██ ██ ██ ██ ██
|
|
||||||
██ ███ ██████ █████ ██ ██ ██
|
|
||||||
██ ██ ██ ██ ██ ██
|
|
||||||
██████ ██ ██████ ███████
|
|
||||||
|
|
||||||
gp-dl — Google Photos Downloader
|
|
||||||
Download full-res albums using Selenium
|
|
||||||
|
|
||||||
Author: csd4ni3l | GitHub: https://github.com/csd4ni3l
|
|
||||||
"""
|
|
||||||
|
|
||||||
def parse_args():
|
|
||||||
parser = argparse.ArgumentParser(description="Download full-res images from a Google Photos album using Selenium.")
|
|
||||||
parser.add_argument("--album-urls", nargs="+", required=True, help="Google Photos album URL(s)")
|
|
||||||
parser.add_argument("--output-dir", required=True, help="Directory to save downloaded albums")
|
|
||||||
parser.add_argument("--driver-path", default=None, help="Custom Chrome driver path")
|
|
||||||
parser.add_argument("--profile-dir", default=None, help="Chrome user data directory for session reuse")
|
|
||||||
parser.add_argument("--headless", action="store_true", help="Run Chrome headlessly")
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
def setup_driver(driver_path=None, profile_dir=None, headless=True):
|
|
||||||
chrome_options = Options()
|
|
||||||
if profile_dir:
|
|
||||||
chrome_options.add_argument(f"--user-data-dir={profile_dir}")
|
|
||||||
if headless:
|
|
||||||
chrome_options.add_argument("--headless")
|
|
||||||
|
|
||||||
prefs = {
|
|
||||||
"download.prompt_for_download": False,
|
|
||||||
"download.default_directory": os.path.join(os.getcwd(), "gp_temp"),
|
|
||||||
"profile.default_content_setting_values.automatic_downloads": 1
|
|
||||||
}
|
|
||||||
|
|
||||||
chrome_options.add_experimental_option("prefs", prefs)
|
|
||||||
chrome_options.add_argument("--disable-gpu")
|
|
||||||
chrome_options.add_argument("--window-size=1920,1080")
|
|
||||||
|
|
||||||
if driver_path:
|
|
||||||
service = ChromeService(executable_path=driver_path)
|
|
||||||
return Chrome(options=chrome_options, service=service)
|
|
||||||
else:
|
|
||||||
return Chrome(options=chrome_options)
|
|
||||||
|
|
||||||
def find_zip_file():
|
|
||||||
for file in os.listdir("gp_temp"):
|
|
||||||
if file.endswith(".zip"):
|
|
||||||
return file
|
|
||||||
|
|
||||||
def find_crdownload_file():
|
|
||||||
for file in os.listdir("gp_temp"):
|
|
||||||
if file.endswith(".crdownload"):
|
|
||||||
return file
|
|
||||||
|
|
||||||
def configure_logging():
|
|
||||||
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=logging.DEBUG)
|
|
||||||
for logger_to_disable in ["selenium", "urllib3"]:
|
|
||||||
logging.getLogger(logger_to_disable).propagate = False
|
|
||||||
logging.getLogger(logger_to_disable).disabled = True
|
|
||||||
|
|
||||||
def main():
|
|
||||||
args = parse_args()
|
|
||||||
driver = setup_driver(profile_dir=args.profile_dir, headless=args.headless)
|
|
||||||
|
|
||||||
if not os.path.exists("gp_temp") or not os.path.isdir("gp_temp"):
|
|
||||||
logging.info("Creating gp_temp directory to temporarily store the downloaded zip files.")
|
|
||||||
os.makedirs("gp_temp", exist_ok=True)
|
|
||||||
|
|
||||||
if not os.path.exists(args.output_dir) or not os.path.isdir(args.output_dir):
|
|
||||||
logging.fatal("Invalid output directory. Please supply a valid and existing directory.")
|
|
||||||
return
|
|
||||||
|
|
||||||
failed_album_count = 0
|
|
||||||
successful_album_count = 0
|
|
||||||
total_albums = len(args.album_urls)
|
|
||||||
all_start = time.perf_counter()
|
|
||||||
album_times = []
|
|
||||||
|
|
||||||
for album_url in args.album_urls:
|
|
||||||
album_start = time.perf_counter()
|
|
||||||
|
|
||||||
if re.match(r'^https?://photos\.app\.goo\.gl/[A-Za-z0-9]+$', album_url) is None:
|
|
||||||
logging.error(f"Invalid album URL: {album_url}")
|
|
||||||
logging.info("Continuing with next album URL.")
|
|
||||||
failed_album_count += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
logging.info(f"Now downloading {album_url}")
|
|
||||||
|
|
||||||
driver.get(album_url)
|
|
||||||
|
|
||||||
logging.debug("Waiting for menu button...")
|
|
||||||
try:
|
|
||||||
menu_button = WebDriverWait(driver, 10).until(EC.presence_of_element_located((By.XPATH, '//*[@aria-label="More options"]')))
|
|
||||||
except TimeoutException:
|
|
||||||
logging.error("Could not find more options button in time.")
|
|
||||||
logging.info("Continuing with next album URL.")
|
|
||||||
failed_album_count += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
logging.debug("Clicking menu button...")
|
|
||||||
menu_button.click()
|
|
||||||
|
|
||||||
logging.debug("Waiting for download all button...")
|
|
||||||
try:
|
|
||||||
download_all_button = WebDriverWait(driver, 10).until(EC.presence_of_element_located((By.XPATH, '//*[@aria-label="Download all"]')))
|
|
||||||
except TimeoutException:
|
|
||||||
logging.error("Could not find download all button in time.")
|
|
||||||
logging.info("Continuing with next album.")
|
|
||||||
failed_album_count += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
logging.debug("Clicking the download all button...")
|
|
||||||
download_all_button.click()
|
|
||||||
|
|
||||||
logging.info("Waiting for Google to prepare the file...")
|
|
||||||
crdownload_file = None
|
|
||||||
while not crdownload_file:
|
|
||||||
crdownload_file = find_crdownload_file()
|
|
||||||
time.sleep(0.1)
|
|
||||||
|
|
||||||
logging.info("Waiting for the download to finish...")
|
|
||||||
zip_file = None
|
|
||||||
while not zip_file:
|
|
||||||
zip_file = find_zip_file()
|
|
||||||
time.sleep(0.1)
|
|
||||||
|
|
||||||
logging.debug(f"Zip file downloaded, extracting to {args.output_dir}")
|
|
||||||
|
|
||||||
with ZipFile(f"gp_temp/{zip_file}") as opened_file:
|
|
||||||
opened_file.extractall(args.output_dir)
|
|
||||||
|
|
||||||
logging.debug("Deleting zip file...")
|
|
||||||
os.remove(f"gp_temp/{zip_file}")
|
|
||||||
|
|
||||||
logging.info(f"Succesfully extracted zip file to {args.output_dir}")
|
|
||||||
|
|
||||||
successful_album_count += 1
|
|
||||||
album_times.append(time.perf_counter() - album_start)
|
|
||||||
|
|
||||||
logging.debug("Removing temporary gp_temp directory.")
|
|
||||||
os.removedirs("gp_temp")
|
|
||||||
|
|
||||||
print("\n===== DOWNLOAD STATISTICS =====")
|
|
||||||
print(f"Total albums given: {total_albums}")
|
|
||||||
print(f"Successfully downloaded: {successful_album_count}")
|
|
||||||
print(f"Failed downloads: {failed_album_count}")
|
|
||||||
print(f"Average time taken per album: {sum(album_times) / len(album_times):.2f} seconds")
|
|
||||||
print(f"Total time taken: {time.perf_counter() - all_start:.2f} seconds")
|
|
||||||
print("================================")
|
|
||||||
|
|
||||||
driver.quit()
|
|
||||||
|
|
||||||
def run_cli():
|
|
||||||
print(BANNER)
|
|
||||||
configure_logging()
|
|
||||||
main()
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "gp-dl"
|
name = "gp-dl"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
description = "A Python-based Google Photos downloader built with Selenium."
|
description = "A Python-based Google Photos downloader built with Selenium."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
@@ -18,8 +18,15 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
gp-dl = "gp_dl.main:run_cli"
|
gp-dl = "gp_dl.cli:run_cli"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=61.0"]
|
requires = ["setuptools>=61.0"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
packages = ["gp_dl"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
gp_dl = ["locales/*.json"]
|
||||||
Reference in New Issue
Block a user