Mã nguồn đầy đủ 3 - kèo trực tuyến
Bắt đầu từ đâu
Tôi muốn thêm chức năng Newsletter cho blog của mình và tốt nhất là hỗ trợ chuyển đổi RSS sang Newsletter. Như vậy, mỗi khi blog được cập nhật, bản tin cũng sẽ tự động cập nhật theo.
Hiện nay có khá nhiều dịch vụ cung cấp tính năng này, nhưng phần lớn đều tính phí và không phải rẻ, ví dụ như Mailchimp nổi tiếng. Ngoài ra cũng có những dịch vụ miễn phí như Mailbrew mà tôi đã sử dụng trong một thời gian, nhưng sản phẩm này hiện không còn được duy trì, thậm chí gần đây tôi không thể đăng nhập được nên buộc phải dừng sử dụng.
Tự làm lấy thì mới đủ ăn! Vì vậy, tôi bắt đầu nghiên cứu và thực hiện.
Ý tưởng
Để chuyển đổi RSS sang Email, chúng ta chỉ cần hai chức năng chính:
- Công cụ biểu mẫu để thu thập địa chỉ email của độc giả, cần hỗ trợ hủy đăng ký.
- Dịch vụ gửi email để phân tích nội dung RSS và gửi qua email.
Điểm thứ hai chỉ cần tìm một máy chủ gửi email, sau đó sử dụng kịch bản Python, rất dễ dàng giải quyết. Tôi đã chọn dịch vụ AWS SES của Amazon, tính phí theo số lượng email gửi đi, với giá 0,1 đô la cho sv 88 mỗi 1000 email. Với tần suất cập nhật blog và số lượng người đăng ký hiện tại của tôi, chi phí này hầu như không đáng kể.
Về điểm thứ nhất, tôi không muốn (thực tế là cũng không biết) viết mã code phía trước, càng không muốn phát triển hệ thống phía sau hay quản lý cơ sở dữ liệu. Do đó, tôi cân nhắc việc sử dụng một cơ sở dữ liệu bên thứ ba hỗ trợ API để thao tác. Danh sách email độc giả là dữ liệu quan trọng, vì vậy cần một cơ sở dữ liệu đáng tin cậy và dễ quản lý. Notion Database và Google Sheet là hai lựa chọn phù hợp.
Sau khi cân nhắc, tôi đã chọn 99win club Notion, kết hợp cùng NotionForms. NotionForms cung cấp trang biểu mẫu / thành phần để thu thập dữ liệu và lưu trữ vào Database của Notion. Phiên bản miễn phí không giới hạn số lượng dữ liệu được thu thập, hoàn toàn đáp ứng nhu cầu của tôi.
OK, giờ là lúc quảng cáo! Mời bạn điền biểu mẫu dưới đây để đăng ký nhận bản tin. Hiện tại, tôi chủ yếu gửi bản tin hàng tuần, cập nhật vào sáng thứ Hai hằng tuần.
(Lưu trình của tôi đã được kiểm tra kỹ lưỡng và hoạt động tốt, tuy nhiên đây là lần đầu tiên gửi vào thứ Hai tới, nếu nhận được nội dung kỳ lạ nào, mong bạn nhẹ tay góp ý!)
Một điểm đáng tiếc của phiên bản miễn phí NotionForms là không hỗ trợ cập nhật dữ liệu, nghĩa là không thể trực tiếp hỗ trợ hủy đăng ký. Giải pháp tạm thời của tôi là: cung cấp một biểu mẫu riêng biệt và cơ sở dữ liệu Notion tương ứng cho việc hủy đăng ký. Khi nhận được yêu cầu hủy đăng ký, tôi sẽ xóa thủ công danh sách người đăng ký. Nếu có quá nhiều yêu cầu hủy đăng ký, tôi có thể viết một kịch bản dựa trên API của Notion để xử lý, điều này sẽ được cân nhắc sau này.
Thực hiện kỹ thuật
Dựa trên ý tưởng trên, tất cả công việc có thể hoàn thành bằng một kịch bản Python, chỉ cần ba bước đơn giản:
- Đọc danh sách email từ Database của Notion.
- Thu thập dữ liệu RSS để lấy thông tin cập nhật blog.
- Gọi API của AWS SES để gửi email.
Cuối cùng, cấu hình crontab để chạy nhiệm vụ tự động mỗi ngày.
Chuẩn bị
Tất nhiên, cần chuẩn bị một số điều kiện trước khi bắt đầu, bao gồm các bước sau:
- Chuẩn bị Database
- Chuẩn bị biểu mẫu
- Chuẩn bị Token Notion và quyền truy cập
- Hệ thống sẽ tạo ra một Token, vui lòng lưu giữ kỹ vì sẽ cần sử dụng trong kịch bản Python sau này.
- Quay lại Database "Danh sách đăng ký" trên Notion, nhấp vào menu Database, chọn "Add connections", tìm và thêm quyền cho "NewsletterAPP" vừa tạo.
- Chuẩn bị AWS SES và quyền truy cập
- Tài khoản mới tạo sẽ ở chế độ sandbox, không thể gửi email ra ngoài, cần nộp phiếu yêu cầu rời khỏi sandbox. Trong phiếu yêu cầu cần giải thích rõ ràng mục đích sử dụng, số lượng email dự kiến gửi đi, cách xử lý email bị trả về, v.v.
- Cần tạo một tài khoản IAM và lấy
aws_access_key_id
vàaws_secret_access_key
, lưu vào file~/.aws/credentials
.
- Các thư viện Python cần thiết
- requests: Không cần giải thích thêm.
- feedparser: Thư viện phân tích RSS, rất dễ sử dụng.
- boto3: Thư viện chính thức của AWS SES, giúp gửi email rất thuận tiện.
Mã nguồn đầy đủ
- Chạy mã nguồn, cần đặt thông tin xác thực AWS SES vào file
~/.aws/credentials
. Các thông tin khác có thể bổ sung trực tiếp vào mã nguồn. - Mã nguồn đầy đủ:
1#!/usr/local/bin/python3
2# -*- coding: UTF-8 -*-
3import boto3, feedparser, requests, re, datetime
4from botocore.exceptions import ClientError
5from time import mktime
6
7# ++++++++++
8# Chuẩn bị các tham số
9# ++++++++++
10# Tham số Notion
11EMAIL_DATABASE_ID = '' # ID database danh sách đăng ký, có trong URL của database
12NOTION_API_TOKEN = '' # Xem trong trang integration của Notion
13EMAIL_DATABASE_URL = ''
14HEADERS = {
15 'accept': 'application/json',
16 'Authorization': 'Bearer {token}'.format(token=NOTION_API_TOKEN),
17 'Notion-Version': '2022-06-28',
18 'content-type': 'application/json'
19}
20PAGE_SIZE = 100
21
22# Tham số AWS SES
23REGION_NAME = 'ap-northeast-2' # Vùng AWS SES, xem trong tài khoản AWS
24SOURCE = 'Name <email_address>' # Địa chỉ email đã được xác thực trong AWS SES
25client = boto3.client('ses', region_name=REGION_NAME)
26
27# Địa chỉ email cá nhân và RSS, dùng để nhận thông báo
28NOTI_MYSELF_EMAIL = 'email_address' # Sau mỗi lần gửi bản tin, sẽ gửi thông báo đến email này
29BLOG_URL = '' # URL blog, dùng trong phần cuối email để xem thêm
30RSS_URL = '' # Địa chỉ RSS cần chuyển đổi thành bản tin
31AUTHOR_URL = '' # Trang chủ cá nhân của tác giả, dùng trong phần tác giả của bản tin
32UNSCRIBE_URL = '' # Đường dẫn biểu mẫu hủy đăng ký, dùng trong phần cuối email
33
34# ++++++++++
35# Bắt đầu triển khai chức năng
36# ++++++++++
37# Lấy danh sách email từ API của Notion
38# Trong cơ sở dữ liệu Notion, địa chỉ email được lưu trữ trong cột Email, kiểu dữ liệu là Email
39# Cần có ít nhất một địa chỉ email trong Notion, mã nguồn chưa hỗ trợ trường hợp không có địa chỉ
40def get_emails():
41 payload = {"page_size": PAGE_SIZE}
42 emails = []
43 response = requests.post(EMAIL_DATABASE_URL, json=payload, headers=HEADERS)
44 for result in response.json()['results']:
45 if result['properties']['Email']['email']:
46 emails.append(result['properties']['Email']['email'])
47 next_cursor = response.json()['next_cursor']
48 while next_cursor:
49 payload = {"page_size": PAGE_SIZE, 'start_cursor': next_cursor}
50 response = requests.post(EMAIL_DATABASE_URL, json=payload, headers=HEADERS)
51 for result in response.json()['results']:
52 if result['properties']['Email']['email']:
53 emails.append(result['properties']['Email']['email'])
54 next_cursor = response.json()['next_cursor']
55 # Kiểm tra định dạng và loại bỏ trùng lặp danh sách email
56 emails = list(set(filter_email(emails)))
57 return emails
58
59# Hàm kiểm tra định dạng email, lọc bỏ email không hợp lệ
60def filter_email(emails):
61 pattern = r'(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)'
62 result = []
63 for e in emails:
64 if re.match(pattern, e.strip()):
65 result.append(e.strip())
66 return result
67
68# Lấy nội dung bài viết từ RSS và xử lý nội dung HTML cho email, sử dụng thư viện feedparser
69# Chỉ lấy bài viết mới nhất, trả về kết quả là một từ điển bao gồm tiêu đề,
70def get_article():
71 rss_weekly = feedparser.parse(RSS_URL)
72 title = rss_weekly['entries'][0].title
73 link = rss_weekly['entries'][0].link
74 content = rss_weekly['entries'][0].content[0].value.replace('<a href="', '<a style="color:#3354AA" href="')
75 published = datetime.datetime.fromtimestamp(mktime(rss_weekly['entries'][0].published_parsed)) + datetime.timedelta(hours=8)
76
77 # Tạo nội dung email từ các trường bài viết
78 content_html = '''
79 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "
80 <html xmlns="
81 <head>
82 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
83 <title>
84 {title}
85 </title>
86 <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
87 </head>
88 <body style="margin: 0; padding: 0; font-size: 1.2em; color:#111; text-decoration:none; ">
89 <table cellpadding="0" cellspacing="0" width="100%">
90 <tr>
91 <td>
92 <table align="center" border="0" cellpadding="5" cellspacing="0" width="600" style="border-collapse: collapse; border-color:lightgray; ">
93 <tr>
94 <td>
95 <h1>
96 <a style="color:black;text-decoration:none;" href="{link}">{title}</a>
97 </h1>
98 <p>
99 <i>by <a style="color:black" href="{author_url}">Tháng Mười</a></i>
100 </p>
101 <p>
102 {content}
103 </p>
104 </td>
105 </tr>
106 <tr>
107 <td>-- EOF --</td>
108 </tr>
109 <tr>
110 <td>
111 <p>
112 Link:<a style="color:black;" href="{blog_url}">Xem các bản tin trước</a> |
113 <a style="color:black;" href="{unsubscribe_url}">Hủy đăng ký</a>
114 </p>
115 </td>
116 </tr>
117 </table>
118 </td>
119 </tr>
120 </table>
121 </body>
122 </html>
123 '''.format(
124 title=title,
125 link=link,
126 content=content,
127 blog_url = BLOG_URL,
128 unsubscribe_url = UNSCRIBE_URL,
129 author_url = AUTHOR_URL
130 )
131
132 return {
133 'title': title,
134 'link': link,
135 'published': published,
136 'content_html': content_html
137 }
138
139# Hàm ghi log, dùng để lưu thông tin thành công hoặc lỗi vào file log
140def write_log(text):
141 with open('email_send_log.txt', 'a') as f:
142 f.write(text)
143
144# Hàm gửi email, tham số:
145# Loại: Thông báo gửi cho bản thân hoặc gửi bài viết cho độc giả, sẽ được ghi log
146# Người nhận:
147# Tiêu đề:
148# Nội dung:
149# Giá trị trả về: Trạng thái gửi, True hoặc False
150def send_email(email_type, to_email, title, body):
151 try:
152 # Cố gắng gửi email bằng AWS SES
153 response = client.send_email(
154 Destination={
155 'ToAddresses': [
156 to_email,
157 ],
158 },
159 Message={
160 'Body': {
161 'Html': {
162 'Charset': "UTF-8",
163 'Data': body
164 },
165 },
166 'Subject': {
167 'Charset': "UTF-8",
168 'Data': title,
169 },
170 },
171 Source=SOURCE,
172 )
173 # Lưu log thành công hoặc lỗi và trả về trạng thái gửi
174 except ClientError as e:
175 log = '{dt} {email_type} to {email} error info: {error}\n'.format(
176 dt=datetime.datetime.now(),
177 error=e.response['Error']['Message'],
178 email = to_email,
179 email_type = email_type
180 )
181 write_log(log)
182 return False
183 else:
184 log = "{dt} {email_type} to {email} success message_id: {messageid}\n".format(
185 dt = datetime.datetime.now(),
186 messageid=response['MessageId'],
187 email = to_email,
188 email_type = email_type
189 )
190 write_log(log)
191 return True
192
193# Gửi loạt bản tin, trả về số lượng gửi thành công và thất bại
194def send_newsletter():
195 success_cnt = 0
196 failure_cnt = 0
197 emails = get_emails()
198 article = get_article()
199 have_new_post = 0
200 # Kiểm tra thời gian bài viết, nếu là bài viết của ngày hôm nay mới gửi
201 if article['published'].strftime('%Y-%m-%d') == datetime.datetime.now().strftime('%Y-%m-%d'):
202 have_new_post = 1
203 for email_addr in emails:
204 status = send_email('send_newsletter_email', email_addr, article['title'], article['content_html'])
205 if status == True:
206 success_cnt = success_cnt + 1
207 else:
208 failure_cnt = failure_cnt + 1
209 return {
210 'success_cnt': success_cnt,
211 'failure_cnt': failure_cnt,
212 'have_new_post': have_new_post
213 }
214
215if __name__ == '__main__':
216 try:
217 result = send_newsletter()
218 # Ghi log trạng thái chạy script
219 write_log('{dt} run_script success: success_cnt={success_cnt}, failure_cnt={failure_cnt}, new_post={have_new_post}\n'.format(
220 dt=datetime.datetime.now(),
221 success_cnt=result['success_cnt'],
222 failure_cnt=result['failure_cnt'],
223 have_new_post=result['have_new_post']
224 ))
225 # Gửi email thông báo trạng thái chạy script
226 send_email('send_noti_myself', NOTI_MYSELF_EMAIL,
227 'Thông báo chạy script bản tin',
228 '<html>Gửi thành công: {success_cnt}, gửi thất bại: {failure_cnt}, số bài viết cập nhật: {have_new_post}</html>'.format(
229 success_cnt=result['success_cnt'],
230 failure_cnt=result['failure_cnt'],
231 have_new_post=result['have_new_post']
232 ))
233 except Exception as e:
234 write_log('{dt} run_script error: {e}\n'.format(dt=datetime.datetime.now(),e=e))
235 # Gửi email thông báo lỗi chạy script
236 send_email('send_noti_myself', NOTI_MYSELF_EMAIL,
237 'Thông báo chạy script bản tin',
238 '<html>Script chạy gặp lỗi: {e}</html>'.format(e=e))
- Cấu hình công việc định kỳ
Ví dụ, tôi lưu mã nguồn ở đường dẫn
/sky/job/newsletter/newsletter.py
và cấu hình crontab để chạy nhiệm vụ vào lúc 8 giờ sáng mỗi thứ Hai.
10 8 * * 1 /usr/bin/python3 /sky/job/newsletter/newsletter.py
Hy vọng hướng dẫn này hữu ích cho bạn!