Skip to content

Commit b0917ad

Browse files
committed
Add GitHub Actions workflow and update main blog_exporter script to update info from csv, including downloading the blog image
1 parent 2f5ca79 commit b0917ad

9 files changed

Lines changed: 495 additions & 223 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
name: Import Meetup Events
2+
3+
on:
4+
workflow_dispatch:
5+
schedule:
6+
- cron: '0 7 * * *' # check for new blogs at 7:00am
7+
jobs:
8+
check-for-changes:
9+
if: github.repository == 'Women-Coding-Community/WomenCodingCommunity.github.io'
10+
runs-on: ubuntu-latest
11+
outputs:
12+
has_new_rows: ${{ steps.check-blog.outputs.has_new_rows }}
13+
new_row_indices: ${{ steps.check-blog.outputs.new_row_indices }}
14+
15+
steps:
16+
- name: Checkout repository
17+
uses: actions/checkout@v5
18+
19+
- name: Set up Python
20+
uses: actions/setup-python@v5
21+
with:
22+
python-version: '3.12'
23+
24+
- name: Cache pip
25+
uses: actions/cache@v4
26+
with:
27+
path: ~/.cache/pip
28+
key: ${{ runner.os }}-pip-${{ hashFiles('tools/requirements.txt') }}
29+
restore-keys: |
30+
${{ runner.os }}-pip-
31+
32+
- name: Install dependencies
33+
run: |
34+
python -m pip install --upgrade pip
35+
pip install -r tools/requirements.txt
36+
37+
- name: Check for new blog entries
38+
id: check-blog
39+
run: |
40+
cd tools/blog_automation
41+
python check_for_new_blogs.py
42+
43+
# If there are new rows, run the blog_exporter script
44+
run-blog-automation:
45+
needs: check-for-changes
46+
if: needs.check-for-changes.outputs.has_new_rows == 'true'
47+
runs-on: ubuntu-latest
48+
49+
steps:
50+
- name: Checkout repository
51+
uses: actions/checkout@v5
52+
53+
- name: Set up Python
54+
uses: actions/setup-python@v5
55+
with:
56+
python-version: '3.12'
57+
58+
- name: Cache pip
59+
uses: actions/cache@v4
60+
with:
61+
path: ~/.cache/pip
62+
key: ${{ runner.os }}-pip-${{ hashFiles('tools/requirements.txt') }}
63+
restore-keys: |
64+
${{ runner.os }}-pip-
65+
66+
- name: Install dependencies
67+
run: |
68+
python -m pip install --upgrade pip
69+
pip install -r tools/requirements.txt
70+
71+
- name: Export new blogs
72+
run: |
73+
cd tools/blog_automation
74+
for row_index in ${{ needs.check-for-changes.outputs.new_row_indices }}; do
75+
python blog_exporter.py --row_index "$row_index"
76+
done
77+
78+
- name: Create or Update Pull Request
79+
id: create-pr
80+
uses: peter-evans/create-pull-request@v7
81+
with:
82+
token: ${{ secrets.GHA_ACTIONS_ALLOW_TOKEN }}
83+
commit-message: "Automated blog import from Google Docs"
84+
branch: "automation/import-blog"
85+
team-reviewers: "Women-Coding-Community/leaders"
86+
title: "Automated import of blog posts"
87+
body: |
88+
This PR was created automatically by a GitHub Action to import new blog posts.
89+
The new blog posts have been added to `_posts/` directory.
90+
The `blog_info_snapshot.csv` has been updated to track processed entries.
91+
labels: |
92+
automation

tools/blog_automation/README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,17 @@ Use this ID in your scripts when exporting the document.
7777

7878
## Run Automation
7979
1. Activate virtual environment: `source venv/bin/activate`
80-
2. Run the script: `python doc_to_html_conversion.py <DOC_ID>`
80+
2. Run the script: `python blog_exporter [--row_index <ROW_INDEX>]`, where the row_index refers to the row of the CSV. This defaults to -1, or the last row in the CSV.
81+
82+
**Notes and Options**
83+
- The blog csv defaults to blog_info_snapshot.csv
84+
85+
## Tests
86+
87+
Run `pytest test_blog_exporter.py`
88+
89+
## GitHub Actions automation
90+
There is a GitHub Action .github/workflows/run_blog_exporter.yml which checks for any new rows in the blog_info_snapshot.csv, and runs the blog_exporter.py script for each new row.
91+
8192

8293

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
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)

tools/blog_automation/blog_information_from_spreadsheet.py renamed to tools/blog_automation/blog_info_from_spreadsheet.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ def _extract_and_rename_relevant_fields(df):
2121
formatted_df['source'] = df[
2222
'Please provide a source of how you obtained/created the infographic/photo/picture used.'
2323
]
24+
formatted_df['image_link'] = df['Submit your blog cover image']
2425
return formatted_df
2526

2627
def dataframe_of_blog_spreadsheet_info(spreadsheet_id=SPREADSHEET_ID):
@@ -34,11 +35,14 @@ def dataframe_of_blog_spreadsheet_info(spreadsheet_id=SPREADSHEET_ID):
3435
df = pd.DataFrame(data)
3536
return df
3637

37-
if __name__=='__main__':
38+
def save_blog_info_to_csv():
3839
df = dataframe_of_blog_spreadsheet_info()
3940
formatted_df = df.pipe(_extract_and_rename_relevant_fields)
4041
formatted_df.to_csv('blog_info_snapshot.csv')
4142

43+
if __name__=='__main__':
44+
save_blog_info_to_csv()
45+
4246

4347

4448

0 commit comments

Comments
 (0)