Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/docsend_dl/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ async def download_deck(
images_only: bool = False,
on_status: Callable[[str], None] | None = None,
on_slide_done: Callable[[], None] | None = None,
email: str | None = None,
) -> DeckDownloadResult:
"""Download all slides from a public DocSend deck.

Expand All @@ -105,6 +106,8 @@ async def download_deck(
concurrency: Maximum number of concurrent image downloads.
max_retries: Number of retry attempts per image download.
images_only: Save individual PNG images instead of a PDF.
email: Email address for email-gated decks. When provided the
email is submitted automatically. Defaults to None.

Returns:
A :class:`DeckDownloadResult` summarizing the outcome.
Expand All @@ -128,6 +131,7 @@ async def download_deck(
url=url,
headless=headless,
on_status=on_status,
email=email,
)

if images_only:
Expand Down
7 changes: 7 additions & 0 deletions src/docsend_dl/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ def _build_parser() -> argparse.ArgumentParser:
dest="headless",
help="Show the browser window during download",
)
parser.add_argument(
"--email",
type=str,
default=None,
help="Email address for email-gated decks",
)
return parser


Expand Down Expand Up @@ -121,6 +127,7 @@ async def _async_main(args: argparse.Namespace) -> None:
url=args.url,
headless=args.headless,
on_status=lambda msg: status.update(f"[bold blue]{msg}"),
email=args.email,
)

console.print(
Expand Down
55 changes: 45 additions & 10 deletions src/docsend_dl/extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ async def extract_slide_urls(
*,
headless: bool = True,
on_status: Callable[[str], None] | None = None,
email: str | None = None,
) -> DeckInfo:
"""Navigate to a DocSend deck and extract S3 image URLs for all slides.

Expand All @@ -164,13 +165,16 @@ async def extract_slide_urls(
url: Full DocSend URL (e.g. ``https://docsend.com/view/XXXXXX``
or ``https://docsend.com/v/SPACE/NAME``).
headless: Run browser in headless mode. Defaults to True.
email: Email address for email-gated decks. When provided the
email is submitted automatically. Defaults to None.

Returns:
A :class:`DeckInfo` with the deck title, slide count, and image URLs.

Raises:
InvalidURLError: If the URL is not a valid DocSend URL.
EmailGateError: If the deck requires email verification.
EmailGateError: If the deck requires email verification and no
*email* was supplied.
ExtractionError: If slides cannot be found on the page.
"""
def _report(message: str) -> None:
Expand Down Expand Up @@ -208,19 +212,50 @@ def _report(message: str) -> None:
try:
await page.wait_for_selector(".carousel-inner .item", timeout=15_000)
except PlaywrightTimeout:
email_form = await page.query_selector(
'input[type="email"], form[action*="email"], .visitor-email'
email_input = await page.query_selector(
'#link_auth_form_email'
)
await browser.close()
if email_form:
if not email_input:
email_input = await page.query_selector(
'input.js-auth-form_email-field'
)
if not email_input:
email_input = await page.query_selector(
'input[type="email"]'
)
if email_input and email:
_report("Email gate detected — submitting email...")
await email_input.click()
await email_input.fill(email)
continue_btn = await page.query_selector(
'button:has-text("Continue")'
)
if continue_btn:
await continue_btn.click()
else:
await email_input.press("Enter")
try:
await page.wait_for_selector(
".carousel-inner .item", timeout=30_000
)
except PlaywrightTimeout:
await browser.close()
raise ExtractionError(
"Submitted email but slides did not load. "
"The deck may require additional verification."
)
elif email_input:
await browser.close()
raise EmailGateError(
"This deck requires email verification to view. "
"Only public (no-email) decks are supported."
"Pass --email to provide an email address."
)
else:
await browser.close()
raise ExtractionError(
"Could not find slide content on the page. "
"The page may have changed structure or failed to load."
)
raise ExtractionError(
"Could not find slide content on the page. "
"The page may have changed structure or failed to load."
)

# Resolve the canonical /view/ slug from the browser's final URL.
# This handles /v/ URLs that DocSend redirects to /view/SLUG.
Expand Down
Loading