|
| 1 | +import argparse |
| 2 | +import os |
| 3 | +import re |
| 4 | +import shutil |
| 5 | +import datetime as dt |
| 6 | +from pathlib import Path |
| 7 | +import markdown |
| 8 | +import pandas as pd |
| 9 | +from google.oauth2 import service_account |
| 10 | +from googleapiclient.discovery import build |
| 11 | +from googleapiclient.errors import HttpError |
| 12 | + |
| 13 | +# --- Configuration --- |
| 14 | +SERVICE_ACCOUNT_FILE = 'service_account_key.json' |
| 15 | +YAML_HEADER = '''--- |
| 16 | +layout: post |
| 17 | +title: {title} |
| 18 | +date: {date} |
| 19 | +author_name: {author_name} |
| 20 | +author_role: {author_role} |
| 21 | +image: {image_path} |
| 22 | +image_source: {image_source} |
| 23 | +description: {description} |
| 24 | +category: blog |
| 25 | +--- |
| 26 | +''' |
| 27 | + |
| 28 | +def _current_directory(): |
| 29 | + return os.path.dirname(os.path.abspath(__file__)) |
| 30 | + |
| 31 | +def drive_connection(): |
| 32 | + service_account_path = os.path.join(_current_directory(), SERVICE_ACCOUNT_FILE) |
| 33 | + if not os.path.exists(service_account_path): |
| 34 | + print(f"ERROR: Service account key file '{service_account_path}' not found.\n" |
| 35 | + "Please obtain your own Google service account key and place it at this path.\n" |
| 36 | + "(Never commit this file to version control.)") |
| 37 | + exit(1) |
| 38 | + creds = service_account.Credentials.from_service_account_file( |
| 39 | + service_account_path, |
| 40 | + scopes=['https://www.googleapis.com/auth/drive.readonly'] |
| 41 | + ) |
| 42 | + drive = build('drive', 'v3', credentials=creds) |
| 43 | + return drive |
| 44 | + |
| 45 | +def _posts_directory(): |
| 46 | + script_dir = Path(_current_directory()) |
| 47 | + posts_dir = (script_dir / "../../_posts").resolve() |
| 48 | + return posts_dir |
| 49 | + |
| 50 | +def _today_date_str(): |
| 51 | + return dt.date.today().isoformat() |
| 52 | + |
| 53 | +def _create_blog_filename_with_date(doc_name, date_str): |
| 54 | + formatted_blog_title = doc_name.lower().replace(' ', '-').strip() |
| 55 | + filename = f"{date_str}-{formatted_blog_title}" |
| 56 | + return filename |
| 57 | + |
| 58 | +def _get_doc_name_from_drive(doc_id, drive): |
| 59 | + """Fetch document name from Google Drive.""" |
| 60 | + try: |
| 61 | + file = drive.files().get(fileId=doc_id, fields='name').execute() |
| 62 | + return file['name'] |
| 63 | + except HttpError as error: |
| 64 | + print(f"ERROR: Could not fetch document from Drive (ID: {doc_id})\n{error}") |
| 65 | + return None |
| 66 | + |
| 67 | +def _get_doc_content_as_markdown(doc_id, drive): |
| 68 | + """Export Google Doc as markdown.""" |
| 69 | + try: |
| 70 | + request = drive.files().export_media(fileId=doc_id, mimeType='text/markdown') |
| 71 | + file_content = request.execute() |
| 72 | + return file_content.decode('utf-8') |
| 73 | + except HttpError as error: |
| 74 | + print(f"ERROR: Could not export document from Drive (ID: {doc_id})\n{error}") |
| 75 | + return None |
| 76 | + |
| 77 | +def _markdown_to_html(markdown_text): |
| 78 | + """Convert Markdown to HTML with custom formatting.""" |
| 79 | + html = markdown.markdown(markdown_text) |
| 80 | + |
| 81 | + # Remove <strong> tags from inside heading tags |
| 82 | + html = re.sub(r'<h(\d)><strong>(.+?)</strong></h\1>', r'<h\1>\2</h\1>', html) |
| 83 | + |
| 84 | + # Remove the first heading if present |
| 85 | + html = re.sub(r'^<h[1-6]>.*?</h[1-6]>\s*', '', html, flags=re.DOTALL) |
| 86 | + |
| 87 | + # Wrap the body in <div class="text-justify"> |
| 88 | + html_body = f'<div class="text-justify">\n{html}\n</div>' |
| 89 | + |
| 90 | + return html_body |
| 91 | + |
| 92 | +def _download_blog_image(blog_image_drive_link, drive): |
| 93 | + """Download image from Google Drive link.""" |
| 94 | + |
| 95 | + pattern = re.compile(r"(?:id=|/d/)([^/&?]+)") |
| 96 | + |
| 97 | + try: |
| 98 | + file_id = re.search( |
| 99 | + pattern, |
| 100 | + blog_image_drive_link |
| 101 | + ) |
| 102 | + if not file_id: |
| 103 | + raise Exception(f"WARNING: Could not extract file ID from image link: {blog_image_drive_link}") |
| 104 | + |
| 105 | + file_id = file_id.group(1) |
| 106 | + print(f'{file_id=}') |
| 107 | + file_metadata = drive.files().get(fileId=file_id, fields='name, mimeType').execute() |
| 108 | + file_name = file_metadata['name'] |
| 109 | + |
| 110 | + request = drive.files().get_media(fileId=file_id) |
| 111 | + file_content = request.execute() |
| 112 | + |
| 113 | + # Save temporarily |
| 114 | + temp_path = os.path.join(_current_directory(), file_name) |
| 115 | + with open(temp_path, 'wb') as f: |
| 116 | + f.write(file_content) |
| 117 | + |
| 118 | + return temp_path |
| 119 | + except HttpError as error: |
| 120 | + print(f"WARNING: Could not download image from Drive\n{error}") |
| 121 | + return None |
| 122 | + |
| 123 | +def _copy_image_to_blog_assets(image_path, blog_filename): |
| 124 | + """Copy image to assets directory and return relative path.""" |
| 125 | + if not image_path or not os.path.exists(image_path): |
| 126 | + return None |
| 127 | + |
| 128 | + assets_dir = Path(_current_directory()).resolve().parent.parent / 'assets' / 'images' / 'blog' |
| 129 | + assets_dir.mkdir(parents=True, exist_ok=True) |
| 130 | + |
| 131 | + new_image_filename = blog_filename.split('.')[0] + '.' +image_path.split('.')[-1] |
| 132 | + new_image_path = assets_dir / new_image_filename |
| 133 | + |
| 134 | + shutil.copy(image_path, new_image_path) |
| 135 | + |
| 136 | + return f"/assets/images/blog/{new_image_filename}" |
| 137 | + |
| 138 | +# def _get_image_path_from_blog_filename_and_image_extension(blog_filename, image_extension): |
| 139 | +# assets_dir = Path(_current_directory()).resolve().parent.parent / 'assets' / 'images' / 'blog' |
| 140 | +# image_filename = assets_dir / (blog_filename.split('.')[0] + image_extension) |
| 141 | +# return image_filename |
| 142 | + |
| 143 | +def download_image_and_copy_to_repo(image_link, blog_filename, drive): |
| 144 | + downloaded_image_path = _download_blog_image(image_link, drive) |
| 145 | + # if downloaded_image_path is not None: |
| 146 | + # image_path_relative = _get_image_path_from_blog_filename_and_image_extension( |
| 147 | + # blog_filename, image_extension=downloaded_image_path.split('.')[-1] |
| 148 | + # ) |
| 149 | + |
| 150 | + image_path_relative = _copy_image_to_blog_assets( |
| 151 | + downloaded_image_path, |
| 152 | + blog_filename |
| 153 | + ) |
| 154 | + |
| 155 | + os.remove(downloaded_image_path) # Clean up temp file |
| 156 | + |
| 157 | + return image_path_relative |
| 158 | + |
| 159 | + |
| 160 | +def export_blog_from_csv_row(row_index, csv_path=None, doc_id_override=None, date=None): |
| 161 | + """ |
| 162 | + Export a blog from a CSV row. |
| 163 | + |
| 164 | + Args: |
| 165 | + row_index: Index of the row in the CSV |
| 166 | + csv_path: Path to CSV file (defaults to blog_info_snapshot.csv in current dir) |
| 167 | + doc_id_override: Optional Google Doc ID to override the one in CSV |
| 168 | + date: Blog post date (defaults to today) |
| 169 | + |
| 170 | + Returns: |
| 171 | + blog_filename if successful, None otherwise |
| 172 | + """ |
| 173 | + if csv_path is None: |
| 174 | + csv_path = os.path.join(_current_directory(), 'blog_info_snapshot.csv') |
| 175 | + |
| 176 | + if date is None: |
| 177 | + date = _today_date_str() |
| 178 | + |
| 179 | + # Read CSV and get row |
| 180 | + try: |
| 181 | + df = pd.read_csv(csv_path, index_col=0) |
| 182 | + blog_info_ser = df.iloc[row_index] |
| 183 | + except (FileNotFoundError, IndexError) as e: |
| 184 | + print(f"ERROR: Could not read CSV row {row_index}\n{e}") |
| 185 | + return None |
| 186 | + |
| 187 | + # Determine doc_id |
| 188 | + doc_id = doc_id_override or blog_info_ser.get('doc_id') |
| 189 | + |
| 190 | + if pd.isna(doc_id) or not doc_id: |
| 191 | + print(f"SKIP: Row {row_index} has no doc_id (external blog link)") |
| 192 | + raise ValueError("No doc_id found in spreadsheet row. Please specify a doc_id_override.") |
| 193 | + |
| 194 | + # Connect to Google Drive |
| 195 | + drive = drive_connection() |
| 196 | + |
| 197 | + # 1. Get document name and content |
| 198 | + doc_name = _get_doc_name_from_drive(doc_id, drive) |
| 199 | + doc_content = _get_doc_content_as_markdown(doc_id, drive) |
| 200 | + blog_filename = _create_blog_filename_with_date(doc_name, date) |
| 201 | + |
| 202 | + # 2. Convert to HTML |
| 203 | + html_body = _markdown_to_html(doc_content) |
| 204 | + |
| 205 | + # 3. Build YAML header |
| 206 | + author_name = blog_info_ser.get('author_name', 'Unknown') |
| 207 | + author_role = blog_info_ser.get('author_role', '') |
| 208 | + description = blog_info_ser.get('description', '') |
| 209 | + source = blog_info_ser.get('source', '') |
| 210 | + |
| 211 | + |
| 212 | + yaml_header = YAML_HEADER.format( |
| 213 | + title=doc_name.title(), |
| 214 | + date=date, |
| 215 | + author_name=author_name, |
| 216 | + author_role=author_role, |
| 217 | + image_path='[IMAGE_PATH]', # Placeholder, will update after image download |
| 218 | + image_source=source, |
| 219 | + description=description |
| 220 | + ) |
| 221 | + |
| 222 | + # 4. Download image if available |
| 223 | + image_link = blog_info_ser.get('image_link') |
| 224 | + if image_link: |
| 225 | + image_path_relative = download_image_and_copy_to_repo( |
| 226 | + image_link, blog_filename=blog_filename, drive=drive |
| 227 | + ) |
| 228 | + if image_path_relative: |
| 229 | + yaml_header = yaml_header.replace('[IMAGE_PATH]', image_path_relative) |
| 230 | + |
| 231 | + # 5. Combine and save |
| 232 | + final_html = yaml_header + '\n' + html_body |
| 233 | + |
| 234 | + posts_dir = _posts_directory() |
| 235 | + posts_dir.mkdir(parents=True, exist_ok=True) |
| 236 | + |
| 237 | + filename = posts_dir / f"{blog_filename}.html" |
| 238 | + with open(filename, 'w', encoding='utf-8') as f: |
| 239 | + f.write(final_html) |
| 240 | + |
| 241 | + print(f"✓ Exported blog to: {filename}") |
| 242 | + return blog_filename |
| 243 | + |
| 244 | + |
| 245 | +if __name__ == "__main__": |
| 246 | + # example usage: python blog_exporter.py # this will export the blog from the last row of the CSV |
| 247 | + parser = argparse.ArgumentParser(description="Export a blog from CSV row into HTML.") |
| 248 | + parser.add_argument( |
| 249 | + "--row_index", type=int, default=-1, help="Index of the row in blog_info_snapshot.csv" |
| 250 | + ) |
| 251 | + parser.add_argument("--csv_path", help="Path to CSV file (default: blog_info_snapshot.csv)") |
| 252 | + parser.add_argument("--doc_id", help="Override doc_id from CSV") |
| 253 | + parser.add_argument("--date", help="Date for blog post (YYYY-MM-DD). Defaults to today.") |
| 254 | + |
| 255 | + args = parser.parse_args() |
| 256 | + export_blog_from_csv_row(args.row_index, args.csv_path, args.doc_id, args.date) |
0 commit comments