I've just created a script that pulls ALL of the Children's friend music with nice names. You'll need Python and the dependencies listed at the top to run it. Use your favorite ai helper if you can get the code to run.
Code:
"""
Church of Jesus Christ - Friend Music Sheet Music Downloader
Naming convention: YYYY-MM-Song Title.pdf / YYYY-MM-Song Title Simplified.pdf
Also writes a MobileSheets-compatible CSV for import.
"""
import time
import os
import re
import sys
import csv
import requests
import logging
from pypdf import PdfReader
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException, WebDriverException
from webdriver_manager.chrome import ChromeDriverManager
# ---------------------------------------------------------------------------
# Logging - explicit UTF-8 on both handlers to prevent cp1252 crash on Windows
# ---------------------------------------------------------------------------
_fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
_file_handler = logging.FileHandler("downloader.log", encoding="utf-8")
_file_handler.setFormatter(_fmt)
_con_handler = logging.StreamHandler(stream=open(
sys.stdout.fileno(), mode='w', encoding='utf-8', closefd=False
))
_con_handler.setFormatter(_fmt)
log = logging.getLogger(__name__)
log.setLevel(logging.DEBUG)
log.addHandler(_file_handler)
log.addHandler(_con_handler)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
COLLECTION_URL = (
"https://www.churchofjesuschrist.org/media/music/collections/"
"music-from-the-friend?lang=eng"
)
DOWNLOAD_DIR = os.path.join(os.getcwd(), "sheet_music")
CSV_PATH = os.path.join(os.getcwd(), "friend_music_mobilesheets.csv")
PAGE_LOAD_WAIT = 12 # seconds to wait for page elements
DOWNLOAD_RETRY = 3 # attempts per file before giving up
SONG_DELAY = 1.5 # seconds between songs
# MobileSheets CSV columns - only fields we have data for.
# 'filename' is the relative path to the PDF from wherever MobileSheets
# is pointed. Adjust the DOWNLOAD_DIR name if you move the files.
CSV_FIELDNAMES = ['title', 'pages', 'years', 'collections', 'source_types', 'filename']
COLLECTION_NAME = "Friend Music"
SOURCE_TYPE = "churchofjesuschrist.org"
MONTH_MAP = {
'january': 1, 'february': 2, 'march': 3, 'april': 4,
'may': 5, 'june': 6, 'july': 7, 'august': 8,
'september': 9, 'october': 10, 'november': 11, 'december': 12,
'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4,
'jun': 6, 'jul': 7, 'aug': 8, 'sep': 9, 'sept': 9,
'oct': 10, 'nov': 11, 'dec': 12,
}
DATE_RE = re.compile(
r'\b(january|february|march|april|may|june|july|august|september|'
r'october|november|december|jan|feb|mar|apr|jun|jul|aug|sept?|oct|nov|dec)'
r'\b[^\d]*(\d{4})',
re.IGNORECASE
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def sanitize_filename(name: str) -> str:
name = re.sub(r'[<>:"/\\|?*]', '', name)
name = re.sub(r'\s+', ' ', name).strip()
return name[:180]
def slug_to_title(slug: str) -> str:
slug = re.sub(r'-\d{4}$', '', slug)
return slug.replace('-', ' ').title()
def parse_date(text: str) -> tuple[int, int]:
match = DATE_RE.search(text)
if match:
return int(match.group(2)), MONTH_MAP.get(match.group(1).lower(), 0)
return 0, 0
def count_pdf_pages(filepath: str) -> int:
"""Return the number of pages in a PDF, or 1 on any error."""
try:
return len(PdfReader(filepath).pages)
except Exception:
return 1
def write_csv_row(writer, song: dict, filename: str, filepath: str):
"""Append one row to the MobileSheets CSV."""
page_count = count_pdf_pages(filepath)
page_range = f"1-{page_count}"
# Relative path: just the folder/filename so MobileSheets can find it
# regardless of drive letter. Adjust if your MobileSheets root differs.
relative_path = os.path.join("sheet_music", filename).replace("\\", "/")
writer.writerow({
'title': song['title'],
'pages': page_range,
'years': str(song['year']) if song['year'] > 0 else '',
'collections': COLLECTION_NAME,
'source_types': SOURCE_TYPE,
'filename': relative_path,
})
# ---------------------------------------------------------------------------
# Driver
# ---------------------------------------------------------------------------
def build_driver() -> webdriver.Chrome:
options = Options()
# options.add_argument("--headless=new") # uncomment to run invisibly
options.add_argument("--disable-gpu")
options.add_argument("--window-size=1920,1080")
options.add_argument("--log-level=3")
return webdriver.Chrome(
service=Service(ChromeDriverManager().install()), options=options
)
# ---------------------------------------------------------------------------
# Catalog building
# ---------------------------------------------------------------------------
def extract_song_metadata(driver) -> list[dict]:
script = """
let seen = new Set();
let results = [];
document.querySelectorAll("a[href*='/media/music/songs/']").forEach(link => {
let url = link.href.split('?')[0];
if (seen.has(url)) return;
seen.add(url);
let card = link.closest('li, article, div[class*="card"], div[class*="item"]');
let cardText = card ? card.textContent : link.parentElement.textContent;
results.push({ url: url, cardText: cardText.toLowerCase() });
});
return results;
"""
return driver.execute_script(script)
def build_catalog(raw_songs: list[dict]) -> dict:
catalog = {}
for item in raw_songs:
url = item['url']
card_text = item['cardText']
slug = url.split('/')[-1]
is_simplified = 'simplified' in card_text or 'simplified' in slug
title = sanitize_filename(slug_to_title(slug))
if is_simplified:
title = re.sub(r'\bSimplified\b', '', title, flags=re.IGNORECASE).strip()
year, month = parse_date(card_text)
key = f"{slug}-simplified" if is_simplified else slug
entry = {
'url': url, 'slug': slug, 'title': title,
'year': year, 'month': month, 'is_simplified': is_simplified,
}
if key in catalog:
ex = catalog[key]
if year > ex['year'] or (year == ex['year'] and month > ex['month']):
catalog[key] = entry
else:
catalog[key] = entry
log.info(f"Catalog built: {len(catalog)} unique songs.")
return catalog
def build_filename(song: dict) -> str:
y = f"{song['year']:04d}" if song['year'] > 0 else "0000"
m = f"{song['month']:02d}" if song['month'] > 0 else "00"
suffix = " Simplified" if song['is_simplified'] else ""
return f"{y}-{m}-{song['title']}{suffix}.pdf"
# ---------------------------------------------------------------------------
# Download
# ---------------------------------------------------------------------------
def sync_cookies(driver, session: requests.Session):
session.cookies.clear()
for c in driver.get_cookies():
session.cookies.set(c['name'], c['value'], domain=c.get('domain', ''))
def find_pdf_url_in_source(driver) -> str | None:
source = driver.page_source
SKIP = ('privacy', 'terms', 'legal', 'policy', 'help', '/imgs/', 'image')
abs_matches = re.findall(
r'["\' ](https?://[^"\'<>\s]+\.pdf)["\' \s,<]',
source, re.IGNORECASE
)
rel_matches = re.findall(
r'["\'] (/[^"\'<>\s]+\.pdf)["\'\s,<]',
source, re.IGNORECASE
)
all_urls = abs_matches + [
f"https://www.churchofjesuschrist.org{p}" for p in rel_matches
]
filtered = [u for u in all_urls if not any(s in u.lower() for s in SKIP)]
cdn = [u for u in filtered if 'assets.churchofjesuschrist.org' in u]
return cdn[0] if cdn else (filtered[0] if filtered else None)
def find_pdf_url_in_dom(driver) -> str | None:
return driver.execute_script("""
function allAnchors(root) {
let found = [];
root.querySelectorAll('*').forEach(el => {
if (el.tagName === 'A' && el.href) found.push(el);
if (el.shadowRoot) found = found.concat(allAnchors(el.shadowRoot));
});
return found;
}
let anchors = allAnchors(document);
// Strategy 1: sheet+pdf text AND href must actually contain .pdf
for (let a of anchors) {
let txt = (a.textContent || '').toLowerCase();
let href = a.href.toLowerCase();
if (txt.includes('sheet') && txt.includes('pdf') && href.includes('.pdf')) {
return a.href;
}
}
// Strategy 2: any href with .pdf
for (let a of anchors) {
if (a.href.toLowerCase().includes('.pdf')) return a.href;
}
return null;
""")
def get_page_title(driver) -> str | None:
"""
Return only the first non-empty line of the H1.
The site appends arranger/copyright names as a second line inside the
same H1 element e.g. "Come Come Ye Saints\nRichards". Taking only the
first line strips those trailing names cleanly.
"""
return driver.execute_script("""
let h = document.querySelector('h1');
if (!h) return null;
let lines = h.textContent.split(/[\\n\\r]+/).map(l => l.trim()).filter(l => l);
return lines.length > 0 ? lines[0] : null;
""")
def download_pdf(session: requests.Session, pdf_url: str, filepath: str) -> bool:
for attempt in range(1, DOWNLOAD_RETRY + 1):
try:
r = session.get(pdf_url, timeout=30, stream=True)
if r.status_code == 200:
with open(filepath, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
return True
log.warning(f" Attempt {attempt}: HTTP {r.status_code}")
except requests.RequestException as e:
log.warning(f" Attempt {attempt}: Network error - {e}")
time.sleep(2 ** attempt)
return False
def safe_get(driver_ref: list, url: str):
driver = driver_ref[0]
try:
driver.get(url)
except WebDriverException as e:
log.warning(f"WebDriver died ({e}). Restarting Chrome...")
try:
driver.quit()
except Exception:
pass
driver_ref[0] = build_driver()
driver_ref[0].get(url)
def process_songs(driver_ref: list, catalog: dict, csv_writer):
session = requests.Session()
session.headers.update({
'User-Agent': (
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/120.0.0.0 Safari/537.36'
)
})
total = len(catalog)
for idx, (key, song) in enumerate(catalog.items(), 1):
safe_get(driver_ref, song['url'])
driver = driver_ref[0]
# Resolve authoritative title from H1
page_title = get_page_title(driver)
if page_title:
clean = sanitize_filename(page_title)
if clean and clean.lower() not in ('simplified', ''):
clean = re.sub(
r'\s*[-]\s*simplified\s*$', '', clean, flags=re.IGNORECASE
).strip()
song['title'] = clean
filename = build_filename(song)
filepath = os.path.join(DOWNLOAD_DIR, filename)
log.info(f"[{idx}/{total}] {filename}")
# If already downloaded, still write CSV row (idempotent re-runs)
if os.path.exists(filepath) and os.path.getsize(filepath) > 0:
log.info(" [SKIP] Already downloaded")
write_csv_row(csv_writer, song, filename, filepath)
continue
sync_cookies(driver, session)
try:
btn = WebDriverWait(driver, PAGE_LOAD_WAIT).until(
EC.presence_of_element_located((By.XPATH,
"//button[contains("
"translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')"
", 'download')]"
))
)
driver.execute_script("arguments[0].click();", btn)
def get_pdf(d):
return find_pdf_url_in_dom(d) or find_pdf_url_in_source(d)
pdf_url = WebDriverWait(driver, PAGE_LOAD_WAIT).until(get_pdf)
log.debug(f" PDF URL: {pdf_url[:100]}")
success = download_pdf(session, pdf_url, filepath)
if success:
log.info(" [OK] Saved")
write_csv_row(csv_writer, song, filename, filepath)
else:
log.error(f" [FAIL] Download failed after {DOWNLOAD_RETRY} attempts")
if os.path.exists(filepath):
os.remove(filepath)
except TimeoutException:
log.info(" [SKIP] No PDF found (likely MP3-only song)")
except WebDriverException as e:
log.warning(f" [CRASH] WebDriver error - restarting Chrome. Detail: {e}")
try:
driver.quit()
except Exception:
pass
driver_ref[0] = build_driver()
except Exception as e:
log.error(f" [ERROR] {song['slug']}: {e}")
time.sleep(SONG_DELAY)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
log.info(f"Download directory: {DOWNLOAD_DIR}")
log.info(f"CSV output: {CSV_PATH}")
driver_ref = [build_driver()]
# Open CSV once - newline='' is required by Python's csv module on Windows
with open(CSV_PATH, 'w', newline='', encoding='utf-8') as csv_file:
writer = csv.DictWriter(csv_file, fieldnames=CSV_FIELDNAMES)
writer.writeheader()
try:
log.info("Loading collection page...")
driver_ref[0].get(COLLECTION_URL)
time.sleep(3)
raw_songs = extract_song_metadata(driver_ref[0])
log.info(f"Raw links found: {len(raw_songs)}")
catalog = build_catalog(raw_songs)
process_songs(driver_ref, catalog, writer)
finally:
try:
driver_ref[0].quit()
except Exception:
pass
log.info(f"Done. PDFs in 'sheet_music/' | CSV at '{CSV_PATH}'")
if __name__ == "__main__":
main()