Commit 0a14389c 0a14389c9e72e597698089b7929d194b2e5a40ab by 沈秋雨

feat(etl): 实现三平台 PostgreSQL 写入函数

1 parent ab7f4ed4
1 import uuid
2
3
4 # ─── QQ Music ────────────────────────────────────────────────────────────────
5
6 def upsert_qq_singers(cur, singers: list[dict]) -> None:
7 if not singers:
8 return
9 sql = """
10 INSERT INTO crawler_qqmusic_singers
11 (id, mid, name, avatar, sex, area, "index", intro, home_url, created_at, updated_at)
12 VALUES (%s, %s, %s, %s, %s::singer_sex, %s::singer_area, %s::singer_index, %s, %s, NOW(), NOW())
13 ON CONFLICT (id) DO NOTHING
14 """
15 rows = [(
16 s['id'], s['mid'], s['name'], s.get('avatar', ''),
17 s.get('sex') or 'U', s.get('area') or '其他', s.get('index') or '#',
18 s.get('intro'), s.get('home_url'),
19 ) for s in singers]
20 cur.executemany(sql, rows)
21
22
23 def upsert_qq_albums(cur, albums: list[dict]) -> None:
24 if not albums:
25 return
26 sql = """
27 INSERT INTO crawler_qqmusic_albums
28 (id, mid, cover, title, intro, type, company_id, company, is_owner, published_at, created_at, updated_at)
29 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
30 ON CONFLICT (id) DO NOTHING
31 """
32 rows = [(
33 a['id'], a.get('mid', ''), a.get('cover', ''), a.get('title', ''),
34 a.get('intro'), a.get('type', ''), a.get('company_id', 0) or 0,
35 a.get('company', ''), a.get('is_owner', 0) or 0, a.get('published_at'),
36 ) for a in albums]
37 cur.executemany(sql, rows)
38
39
40 def upsert_qq_songs(cur, songs: list[dict]) -> None:
41 if not songs:
42 return
43 sql = """
44 INSERT INTO crawler_qqmusic_songs
45 (id, platform_song_id, mid, album_id, cover, title, name, duration,
46 lyric, composer_name, lyricist_name, url, lyric_url,
47 platform_index_url, published_at, singers, status, created_at, updated_at)
48 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 0, NOW(), NOW())
49 ON CONFLICT (platform_song_id) DO NOTHING
50 """
51 rows = [(
52 s['song_uuid'], s['platform_song_id'], s['mid'], s.get('album_id'),
53 s.get('cover', ''), s.get('title', ''), s.get('name', ''), s.get('duration', 0) or 0,
54 s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'),
55 s.get('url', ''), s.get('lyric_url'),
56 s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'),
57 ) for s in songs]
58 cur.executemany(sql, rows)
59
60
61 def upsert_qq_singer_songs(cur, pairs: list[tuple]) -> None:
62 """pairs: [(singer_id, song_uuid), ...]"""
63 if not pairs:
64 return
65 sql = """
66 INSERT INTO crawler_qqmusic_singer_songs (id, singer_id, song_id)
67 VALUES (%s, %s, %s)
68 ON CONFLICT (singer_id, song_id) DO NOTHING
69 """
70 rows = [(str(uuid.uuid4()), singer_id, song_uuid) for singer_id, song_uuid in pairs]
71 cur.executemany(sql, rows)
72
73
74 def upsert_qq_singer_albums(cur, pairs: list[tuple]) -> None:
75 """pairs: [(singer_id, album_id), ...]"""
76 if not pairs:
77 return
78 sql = """
79 INSERT INTO crawler_qqmusic_singer_albums (id, singer_id, album_id)
80 VALUES (%s, %s, %s)
81 ON CONFLICT (singer_id, album_id) DO NOTHING
82 """
83 rows = [(str(uuid.uuid4()), singer_id, album_id) for singer_id, album_id in pairs]
84 cur.executemany(sql, rows)
85
86
87 # ─── Kugou ───────────────────────────────────────────────────────────────────
88
89 def upsert_kugou_singers(cur, singers: list[dict]) -> None:
90 if not singers:
91 return
92 sql = """
93 INSERT INTO crawler_kugou_singers
94 (id, name, avatar, sex, area, "index", intro, home_url, created_at, updated_at)
95 VALUES (%s, %s, %s, %s::kugou_singer_sex, %s::kugou_singer_area, %s::kugou_singer_index, %s, %s, NOW(), NOW())
96 ON CONFLICT (id) DO NOTHING
97 """
98 rows = [(
99 s['id'], s['name'], s.get('avatar', ''),
100 s.get('sex') or 'U', s.get('area') or '其他', s.get('index') or '#',
101 s.get('intro'), s.get('home_url', ''),
102 ) for s in singers]
103 cur.executemany(sql, rows)
104
105
106 def upsert_kugou_albums(cur, albums: list[dict]) -> None:
107 if not albums:
108 return
109 sql = """
110 INSERT INTO crawler_kugou_albums
111 (id, cover, title, intro, type, company_id, company, is_owner, published_at, created_at, updated_at)
112 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
113 ON CONFLICT (id) DO NOTHING
114 """
115 rows = [(
116 a['id'], a.get('cover', ''), a.get('title', ''), a.get('intro'),
117 a.get('type', ''), a.get('company_id', 0) or 0,
118 a.get('company', ''), a.get('is_owner', 0) or 0, a.get('published_at'),
119 ) for a in albums]
120 cur.executemany(sql, rows)
121
122
123 def upsert_kugou_songs(cur, songs: list[dict]) -> None:
124 if not songs:
125 return
126 sql = """
127 INSERT INTO crawler_kugou_songs
128 (id, platform_song_id, hash, album_audio_id, album_id, cover, title, name, duration,
129 lyric, composer_name, lyricist_name, url, lyric_url,
130 platform_index_url, published_at, singers, status, created_at, updated_at)
131 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 0, NOW(), NOW())
132 ON CONFLICT (platform_song_id) DO NOTHING
133 """
134 rows = [(
135 s['song_uuid'], s['platform_song_id'], s.get('hash', ''),
136 s.get('album_audio_id', 0) or 0, s.get('album_id'),
137 s.get('cover', ''), s.get('title', ''), s.get('name', ''), s.get('duration', 0) or 0,
138 s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'),
139 s.get('url', ''), s.get('lyric_url'),
140 s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'),
141 ) for s in songs]
142 cur.executemany(sql, rows)
143
144
145 def upsert_kugou_singer_songs(cur, pairs: list[tuple]) -> None:
146 if not pairs:
147 return
148 sql = """
149 INSERT INTO crawler_kugou_singer_songs (id, singer_id, song_id)
150 VALUES (%s, %s, %s)
151 ON CONFLICT (singer_id, song_id) DO NOTHING
152 """
153 cur.executemany(sql, [(str(uuid.uuid4()), s, sg) for s, sg in pairs])
154
155
156 def upsert_kugou_singer_albums(cur, pairs: list[tuple]) -> None:
157 if not pairs:
158 return
159 sql = """
160 INSERT INTO crawler_kugou_singer_albums (id, singer_id, album_id)
161 VALUES (%s, %s, %s)
162 ON CONFLICT (singer_id, album_id) DO NOTHING
163 """
164 cur.executemany(sql, [(str(uuid.uuid4()), s, a) for s, a in pairs])
165
166
167 # ─── Netease ─────────────────────────────────────────────────────────────────
168
169 def upsert_netease_singers(cur, singers: list[dict]) -> None:
170 if not singers:
171 return
172 sql = """
173 INSERT INTO crawler_netease_singers
174 (id, name, avatar, sex, area, "index", intro, home_url, created_at, updated_at)
175 VALUES (%s, %s, %s, %s::netease_singer_sex, %s::netease_singer_area, %s::netease_singer_index, %s, %s, NOW(), NOW())
176 ON CONFLICT (id) DO NOTHING
177 """
178 rows = [(
179 s['id'], s['name'], s.get('avatar', ''),
180 s.get('sex') or 'U', s.get('area') or '其他', s.get('index') or '#',
181 s.get('intro'), s.get('home_url'),
182 ) for s in singers]
183 cur.executemany(sql, rows)
184
185
186 def upsert_netease_albums(cur, albums: list[dict]) -> None:
187 if not albums:
188 return
189 sql = """
190 INSERT INTO crawler_netease_albums
191 (id, cover, title, intro, type, company_id, company, is_owner, published_at, created_at, updated_at)
192 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
193 ON CONFLICT (id) DO NOTHING
194 """
195 rows = [(
196 a['id'], a.get('cover', ''), a.get('title', ''), a.get('intro'),
197 a.get('type', ''), a.get('company_id', 0) or 0,
198 a.get('company', ''), a.get('is_owner', 0) or 0, a.get('published_at'),
199 ) for a in albums]
200 cur.executemany(sql, rows)
201
202
203 def upsert_netease_songs(cur, songs: list[dict]) -> None:
204 if not songs:
205 return
206 sql = """
207 INSERT INTO crawler_netease_songs
208 (id, platform_song_id, album_id, cover, title, name, duration,
209 lyric, composer_name, lyricist_name, url, lyric_url,
210 platform_index_url, published_at, singers, status, created_at, updated_at)
211 VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 0, NOW(), NOW())
212 ON CONFLICT (platform_song_id) DO NOTHING
213 """
214 rows = [(
215 s['song_uuid'], s['platform_song_id'], s.get('album_id'),
216 s.get('cover', ''), s.get('title', ''), s.get('name', ''), s.get('duration', 0) or 0,
217 s.get('lyric'), s.get('composer_name'), s.get('lyricist_name'),
218 s.get('url', ''), s.get('lyric_url'),
219 s.get('platform_index_url'), s.get('published_at'), s.get('singers_json', '[]'),
220 ) for s in songs]
221 cur.executemany(sql, rows)
222
223
224 def upsert_netease_singer_songs(cur, pairs: list[tuple]) -> None:
225 if not pairs:
226 return
227 sql = """
228 INSERT INTO crawler_netease_singer_songs (id, singer_id, song_id)
229 VALUES (%s, %s, %s)
230 ON CONFLICT (singer_id, song_id) DO NOTHING
231 """
232 cur.executemany(sql, [(str(uuid.uuid4()), s, sg) for s, sg in pairs])
233
234
235 def upsert_netease_singer_albums(cur, pairs: list[tuple]) -> None:
236 if not pairs:
237 return
238 sql = """
239 INSERT INTO crawler_netease_singer_albums (id, singer_id, album_id)
240 VALUES (%s, %s, %s)
241 ON CONFLICT (singer_id, album_id) DO NOTHING
242 """
243 cur.executemany(sql, [(str(uuid.uuid4()), s, a) for s, a in pairs])
1 from unittest.mock import MagicMock, call
2 from etl_to_crawler.writer import upsert_qq_singers, upsert_qq_singer_songs
3
4
5 def test_upsert_qq_singers_executes_insert():
6 cur = MagicMock()
7 singers = [{
8 'id': 1, 'mid': 'abc', 'name': '歌手', 'avatar': 'https://archive-dev.oss-cn-beijing.aliyuncs.com/a.jpg',
9 'sex': 'M', 'area': '华语', 'index': 'G', 'intro': None, 'home_url': None
10 }]
11 upsert_qq_singers(cur, singers)
12 assert cur.executemany.called
13 args = cur.executemany.call_args
14 assert 'crawler_qqmusic_singers' in args[0][0]
15 assert 'ON CONFLICT' in args[0][0]
16
17
18 def test_upsert_qq_singers_empty_does_nothing():
19 cur = MagicMock()
20 upsert_qq_singers(cur, [])
21 cur.executemany.assert_not_called()
22
23
24 def test_upsert_qq_singer_songs():
25 cur = MagicMock()
26 upsert_qq_singer_songs(cur, [(1, 'uuid-abc'), (2, 'uuid-def')])
27 assert cur.executemany.called
28 sql = cur.executemany.call_args[0][0]
29 assert 'crawler_qqmusic_singer_songs' in sql