izuemon commited on
Commit
fd917dd
·
verified ·
1 Parent(s): 4b36152

Update watcher.py

Browse files
Files changed (1) hide show
  1. watcher.py +145 -361
watcher.py CHANGED
@@ -5,6 +5,7 @@ import json
5
  import requests
6
  from datetime import datetime, timezone
7
  from bs4 import BeautifulSoup
 
8
 
9
  # ===== Channel.io 設定 =====
10
  GET_URL = "https://desk-api.channel.io/desk/channels/200605/groups/519217/messages"
@@ -33,8 +34,11 @@ HEADERS_POST = {
33
  "x-account": X_ACCOUNT,
34
  }
35
 
36
- # ===== ssyoutube =====
37
- SSYOUTUBE_URL = "https://ssyoutube.online/yt-video-detail/"
 
 
 
38
 
39
  # ===== Utils =====
40
  def parse_updated_at(value):
@@ -46,15 +50,10 @@ def parse_updated_at(value):
46
 
47
  def extract_youtube_id(text):
48
  patterns = [
49
- # クエリパラメータ v= または vi=
50
  r"(?:[?&](?:v|vi)=)([A-Za-z0-9_-]{11})",
51
- # youtu.be 短縮リンク
52
  r"youtu\.be/([A-Za-z0-9_-]{11})",
53
- # /embed/ /v/ /shorts/
54
  r"youtube(?:-nocookie)?\.com/(?:embed|v|shorts)/([A-Za-z0-9_-]{11})",
55
- # watch? の中に v= が含まれるケース(ioパラメータ等が混ざっても OK)
56
  r"youtube(?:-nocookie)?\.com/watch\?.*?v=([A-Za-z0-9_-]{11})",
57
- # hash や他パラメータに混ざるケース: ...#v=xxxx or &v=xxxx
58
  r"[#&]v=([A-Za-z0-9_-]{11})"
59
  ]
60
  for p in patterns:
@@ -64,276 +63,34 @@ def extract_youtube_id(text):
64
  return None
65
 
66
  def extract_youtube_and_option(text):
67
- """
68
- YouTube URL と画質指定オプションを抽出する
69
- 戻り値: (video_id, option) 例: ("abcdEFGHijk", "720")
70
- """
71
- # 全角スペースも対象に
72
  parts = re.split(r"[ \u3000]+", text.strip())
73
-
74
  video_id = extract_youtube_id(parts[0])
75
  option = None
76
  if len(parts) > 1:
77
  option = parts[1].lower()
78
-
79
  return video_id, option
80
 
81
  def normalize_quality(option):
82
- """
83
- ★ ユーザー入力を内部品質コードへ変換(全角も対応)
84
- """
85
  if option is None:
86
  return "720"
87
-
88
- # 全角 → 半角 に変換
89
- # 例: "720" → "720", "4k" → "4k"
90
  trans_table = str.maketrans(
91
  "0123456789kKpP",
92
  "0123456789kKpP"
93
  )
94
  option = option.translate(trans_table).lower()
95
-
96
  quality_map = {
97
- "144": "144",
98
- "240": "240",
99
- "360": "360",
100
- "480": "480",
101
- "720": "720",
102
- "1080": "1080",
103
- "2k": "1440",
104
- "1440": "1440",
105
- "4k": "2160",
106
- "2160": "2160",
107
- "8k": "4320",
108
- "4320": "4320",
109
- "144p": "144",
110
- "240p": "240",
111
- "360p": "360",
112
- "480p": "480",
113
- "720p": "720",
114
- "1080p": "1080",
115
- "2kp": "1440",
116
- "1440p": "1440",
117
- "4kp": "2160",
118
- "2160p": "2160",
119
- "8kp": "4320",
120
- "4320p": "4320",
121
  }
122
-
123
  if option in quality_map:
124
  return quality_map[option]
125
-
126
  if option in ["a", "f"]:
127
- return option # 特殊指定
128
-
129
- return "720" # デフォルト
130
-
131
- def get_video_title(youtube_url):
132
- """YouTube動画のタイトルを取得する簡易的な関数"""
133
- try:
134
- res = requests.get(youtube_url, timeout=10)
135
- soup = BeautifulSoup(res.text, "lxml")
136
- title_tag = soup.find("meta", property="og:title")
137
- if title_tag:
138
- return title_tag.get("content", "").replace(" - YouTube", "")
139
- except:
140
- pass
141
- return "YouTube動画"
142
-
143
- # ===== ssyoutube HTML 解析 =====
144
- def fetch_download_links(youtube_url):
145
- res = requests.post(
146
- SSYOUTUBE_URL,
147
- data={"videoURL": youtube_url},
148
- timeout=20,
149
- headers={
150
- "User-Agent": "Mozilla/5.0",
151
- "Referer": "https://ssyoutube.online/",
152
- }
153
- )
154
- res.raise_for_status()
155
-
156
- # nonceを抽出
157
- soup = BeautifulSoup(res.text, "lxml")
158
- nonce_match = re.search(r"formData\.append\('nonce', '([^']+)'\);", res.text)
159
- nonce = nonce_match.group(1) if nonce_match else None
160
-
161
- buttons = soup.select("button[data-url]")
162
-
163
- results = []
164
- for btn in buttons:
165
- url = btn.get("data-url")
166
- quality = btn.get("data-quality")
167
- has_audio = btn.get("data-has-audio")
168
-
169
- if not url:
170
- continue
171
-
172
- results.append({
173
- "url": url,
174
- "quality": quality or "audio",
175
- "has_audio": has_audio,
176
- })
177
-
178
- return results, nonce
179
-
180
- # ===== 最高画��映像 + 音声を選択 =====
181
- def select_best_video_and_audio(items):
182
- video_items = []
183
- audio_items = []
184
-
185
- for item in items:
186
- if item["quality"] == "audio":
187
- audio_items.append(item)
188
- elif item["has_audio"] == "false":
189
- m = re.match(r"(\d+)p", item["quality"])
190
- if m:
191
- video_items.append((int(m.group(1)), item))
192
-
193
- if not video_items or not audio_items:
194
- return None, None
195
-
196
- # 最高画質の動画を選択
197
- video_items.sort(key=lambda x: x[0], reverse=True)
198
- best_video = video_items[0][1]
199
-
200
- # 最高音質の音声を選択(例: 128kbps > 64kbps)
201
- # 実際の音声品質情報に基づいて選択する場合は要修正
202
- best_audio = audio_items[0] if audio_items else None
203
-
204
- return best_video, best_audio
205
-
206
- def select_quality_video(items, quality):
207
- """
208
- ★ 指定画質に最適な動画を選択(音声なし映像のみ)
209
- """
210
- video_candidates = []
211
- for item in items:
212
- if item["has_audio"] == "false":
213
- m = re.match(r"(\d+)p", item["quality"])
214
- if m:
215
- q = int(m.group(1))
216
- video_candidates.append((q, item))
217
-
218
- if not video_candidates:
219
- return None
220
-
221
- # 最大画質
222
- video_candidates.sort(key=lambda x: x[0])
223
-
224
- if quality == "f": # 最大画質
225
- return video_candidates[-1][1]
226
-
227
- req = int(quality)
228
- best = None
229
-
230
- # 指定画質以上で一番近いもの
231
- for q, item in video_candidates:
232
- if q >= req:
233
- best = item
234
- break
235
-
236
- # 無ければ最大画質
237
- return best if best else video_candidates[-1][1]
238
-
239
- # ===== ssyoutube サーバー側結合 =====
240
- def merge_video_on_server(video_url, audio_url, video_id, quality, video_title, nonce):
241
- """
242
- ssyoutubeサーバーで動画と音声を結合する
243
- """
244
- # IDと画質からリクエストIDを生成
245
- request_id = f"{video_id}_{quality}p"
246
-
247
- # リクエストデータの構築
248
- request_data = {
249
- "id": request_id,
250
- "ttl": 3600000,
251
- "inputs": [
252
- {
253
- "url": video_url,
254
- "ext": "mp4",
255
- "chunkDownload": {
256
- "type": "header",
257
- "size": 52428800,
258
- "concurrency": 3
259
- }
260
- },
261
- {
262
- "url": audio_url,
263
- "ext": "m4a"
264
- }
265
- ],
266
- "output": {
267
- "ext": "mp4",
268
- "downloadName": f"{video_title}_{quality}p.mp4",
269
- "chunkUpload": {
270
- "size": 104857600,
271
- "concurrency": 3
272
- }
273
- },
274
- "operation": {
275
- "type": "replace_audio_in_video"
276
- }
277
- }
278
-
279
- # 結合開始リクエスト
280
- payload = {
281
- "action": "process_video_merge",
282
- "nonce": nonce,
283
- "request_data": json.dumps(request_data)
284
- }
285
-
286
- headers = {
287
- "User-Agent": "Mozilla/5.0",
288
- "Referer": "https://ssyoutube.online/",
289
- "Origin": "https://ssyoutube.online",
290
- "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
291
- }
292
-
293
- res = requests.post(
294
- "https://ssyoutube.online/wp-admin/admin-ajax.php",
295
- data=payload,
296
- headers=headers,
297
- timeout=60
298
- )
299
- res.raise_for_status()
300
-
301
- result = res.json()
302
-
303
- if not result.get("success") or not result.get("data", {}).get("success"):
304
- raise RuntimeError(f"結合開始に失敗しました: {result}")
305
-
306
- # ステータス監視用URLを取得
307
- monitor_url = result["data"]["result"]["monitor"]["http"]
308
-
309
- # 完了するまでポーリング
310
- max_attempts = 10 # 最大60回試行(30秒間隔で約30分)
311
- attempt = 0
312
-
313
- while attempt < max_attempts:
314
- time.sleep(40) # 30秒間隔でチェック
315
-
316
- status_res = requests.get(monitor_url, timeout=30)
317
- status_res.raise_for_status()
318
- status_data = status_res.json()
319
-
320
- if not status_data.get("success"):
321
- raise RuntimeError(f"ステータス取得に失敗しました: {status_data}")
322
-
323
- status = status_data["result"]["status"]
324
-
325
- if status == "done":
326
- # 完了したらダウンロードURLを返す
327
- return status_data["result"]["output"]["url"]
328
- elif status == "error":
329
- error_msg = status_data["result"]["error"]
330
- raise RuntimeError(f"結合処理でエラーが発生しました: {error_msg}")
331
- # "processing" の場合は継続
332
-
333
- attempt += 1
334
- print(f"結合処理中... 進捗: {status_data['result'].get('progress_in_percent', 0)}%")
335
-
336
- raise RuntimeError("結合処理がタイムアウトしました")
337
 
338
  def send_to_channel(text):
339
  payload = {
@@ -342,7 +99,6 @@ def send_to_channel(text):
342
  {"type": "text", "value": text}
343
  ],
344
  }
345
-
346
  res = requests.post(
347
  POST_URL,
348
  headers=HEADERS_POST,
@@ -351,51 +107,129 @@ def send_to_channel(text):
351
  )
352
  res.raise_for_status()
353
 
354
-
355
- def upload_file_to_channel(file_url):
356
- # mp4バイナリ取得
357
- file_res = requests.get(file_url, timeout=60)
358
- file_res.raise_for_status()
359
-
360
- upload_url = "https://media.channel.io/cht/v1/pri-file/200605/groups/519217/message/send_yt_video_file.mp4"
361
-
362
- files = {
363
- "file": ("video.mp4", file_res.content, "video/mp4")
364
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
 
366
- headers = {
367
- "x-account": X_ACCOUNT,
368
- }
 
 
 
 
 
 
 
 
 
 
369
 
370
- res = requests.post(upload_url, headers=headers, files=files, timeout=60)
371
- res.raise_for_status()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
 
373
- return res.json() # ←これを messages POST の body.files に入れる
 
 
 
 
 
 
374
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
 
376
- def send_video_message(file_json):
377
- request_id = f"desk-web-{int(time.time() * 1000)}"
378
-
379
- payload = {
380
- "requestId": request_id,
381
- "blocks": [
382
- {"type": "text", "value": "プレビュー:"}
383
- ],
384
- "files": [file_json], # ←ここが重要!
385
- }
386
 
387
- res = requests.post(
388
- POST_URL,
389
- headers=HEADERS_POST,
390
- data=json.dumps(payload),
391
- timeout=30
392
- )
393
- res.raise_for_status()
394
 
395
- # ===== Main =====
396
  def main():
397
- processed_messages = set() # 処理済みメッセージを追跡
398
-
399
  while True:
400
  try:
401
  res = requests.get(
@@ -417,8 +251,6 @@ def main():
417
 
418
  if not plain_text or updated_at is None:
419
  continue
420
-
421
- # 既に処理済みのメッセージはスキップ
422
  if msg_id in processed_messages:
423
  continue
424
 
@@ -436,17 +268,18 @@ def main():
436
 
437
  video_id, option = extract_youtube_and_option(latest_msg["plainText"])
438
  if not video_id:
 
 
439
  time.sleep(10)
440
  continue
441
-
442
  valid_options = ["144", "240", "360", "480", "720", "1080", "1440", "2160", "4320", "4k", "8k", "2k", "a", "f"]
443
 
444
- # 画質入力チェックを normalize_quality の前にする
445
  if option is None:
446
  send_to_channel(
447
  "画質が指定されていないため、デフォルトの720pを使用します。\n"
448
  "指定する場合は、動画URLの後にスペースを入れて、"
449
- "144/240/360/480/720/1080/2k(または1440)/4k(または2160)/8k(または4320)/a(結合しない)/f(最高画質) のいずれかを入力してください。"
450
  )
451
  else:
452
  opt_lower = option.lower()
@@ -454,7 +287,7 @@ def main():
454
  send_to_channel(
455
  "不明な画質、または予期しない形式でした。\n"
456
  "対応画質: 144, 240, 360, 480, 720, 1080, "
457
- "2k(1440), 4k(2160), 8k(4320), a(結合無効), f(最大画質)\n"
458
  "動画のURLの後にスペースを入れて指定してください。\n"
459
  "例: https://youtu.be/abcdEFGHijk 1080"
460
  )
@@ -462,68 +295,19 @@ def main():
462
  continue
463
 
464
  quality_opt = normalize_quality(option)
465
-
466
  youtube_url = f"https://www.youtube.com/watch?v={video_id}"
467
- send_to_channel(f"{video_id} のダウンロードを開始します。")
468
-
469
- video_title = get_video_title(youtube_url)
470
- items, nonce = fetch_download_links(youtube_url)
471
-
472
- all_items = items[:]
473
- audio_items = [i for i in items if i["quality"] == "audio"]
474
-
475
- selected_video = None
476
- selected_audio = audio_items[0] if audio_items else None
477
-
478
- if quality_opt not in ["a"]:
479
- selected_video = select_quality_video(items, quality_opt)
480
-
481
- # ダウンロードリスト送信
482
- message_lines = []
483
- message_lines.append(f"<b>{video_title}</b>\n")
484
- message_lines.append("<b>ダウンロードリンク</b>")
485
-
486
- # すべての動画リンクを追加
487
- for item in all_items:
488
- quality = item["quality"]
489
- if item["quality"] == "audio":
490
- quality_display = "音声"
491
- elif item["has_audio"] == "true":
492
- quality_display = f"{quality} (音声付き)"
493
- else:
494
- quality_display = f"{quality} (映像のみ)"
495
-
496
- message_lines.append(f"<link type=\"url\" value=\"{item['url']}\"> {quality_display} </link>")
497
-
498
- message_lines.append("★動画ファイルと音声ファイルをダウンロードして<link type=\"url\" value=\"https://qooly.com/ja/merge-video-and-audio-online\">ここ</link>などで結合すると早くて確実です。")
499
- send_to_channel("\n".join(message_lines))
500
-
501
- # ★ 結合フラグ
502
- do_merge = quality_opt not in ["a"]
503
-
504
- if do_merge and selected_video and selected_audio:
505
- m = re.match(r"(\d+)p", selected_video["quality"])
506
- if m:
507
- q = m.group(1)
508
- send_to_channel(f"{q}pで音声との結合を開始しました。")
509
- try:
510
- merged_url = merge_video_on_server(
511
- selected_video["url"],
512
- selected_audio["url"],
513
- video_id,
514
- q,
515
- video_title,
516
- nonce
517
- )
518
- send_to_channel(f"<link type=\"url\" value=\"{merged_url}\">{q}p 結合済み動画</link>")
519
- file_json = upload_file_to_channel(merged_url)
520
- send_video_message(file_json)
521
- except Exception as e:
522
- send_to_channel(f"結合失敗: {e}")
523
- send_to_channel("完了しました!")
524
- processed_messages.add(latest_msg["id"])
525
 
526
- print(f"送信完了: {video_id}")
 
 
 
 
 
 
527
 
528
  except Exception as e:
529
  print("エラー:", e)
 
5
  import requests
6
  from datetime import datetime, timezone
7
  from bs4 import BeautifulSoup
8
+ from urllib.parse import quote_plus
9
 
10
  # ===== Channel.io 設定 =====
11
  GET_URL = "https://desk-api.channel.io/desk/channels/200605/groups/519217/messages"
 
34
  "x-account": X_ACCOUNT,
35
  }
36
 
37
+ # ===== SaveNow 設定 =====
38
+ # 例示の API キー(実運用では環境変数 SAVENOW_API を設定してください)
39
+ DEFAULT_SAVENOW_API = "dfcb6d76f2f6a9894gjkege8a4ab232222"
40
+ SAVENOW_API = os.getenv("SAVENOW_API", DEFAULT_SAVENOW_API)
41
+ SAVENOW_BASE = "https://p.savenow.to/ajax/download.php"
42
 
43
  # ===== Utils =====
44
  def parse_updated_at(value):
 
50
 
51
  def extract_youtube_id(text):
52
  patterns = [
 
53
  r"(?:[?&](?:v|vi)=)([A-Za-z0-9_-]{11})",
 
54
  r"youtu\.be/([A-Za-z0-9_-]{11})",
 
55
  r"youtube(?:-nocookie)?\.com/(?:embed|v|shorts)/([A-Za-z0-9_-]{11})",
 
56
  r"youtube(?:-nocookie)?\.com/watch\?.*?v=([A-Za-z0-9_-]{11})",
 
57
  r"[#&]v=([A-Za-z0-9_-]{11})"
58
  ]
59
  for p in patterns:
 
63
  return None
64
 
65
  def extract_youtube_and_option(text):
 
 
 
 
 
66
  parts = re.split(r"[ \u3000]+", text.strip())
 
67
  video_id = extract_youtube_id(parts[0])
68
  option = None
69
  if len(parts) > 1:
70
  option = parts[1].lower()
 
71
  return video_id, option
72
 
73
  def normalize_quality(option):
 
 
 
74
  if option is None:
75
  return "720"
 
 
 
76
  trans_table = str.maketrans(
77
  "0123456789kKpP",
78
  "0123456789kKpP"
79
  )
80
  option = option.translate(trans_table).lower()
 
81
  quality_map = {
82
+ "144": "144", "240": "240", "360": "360", "480": "480",
83
+ "720": "720", "1080": "1080", "2k": "1440", "1440": "1440",
84
+ "4k": "2160", "2160": "2160", "8k": "4320", "4320": "4320",
85
+ "144p": "144", "240p": "240", "360p": "360", "480p": "480",
86
+ "720p": "720", "1080p": "1080", "2kp": "1440", "1440p": "1440",
87
+ "4kp": "2160", "2160p": "2160", "8kp": "4320", "4320p": "4320",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  }
 
89
  if option in quality_map:
90
  return quality_map[option]
 
91
  if option in ["a", "f"]:
92
+ return option
93
+ return "720"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
  def send_to_channel(text):
96
  payload = {
 
99
  {"type": "text", "value": text}
100
  ],
101
  }
 
102
  res = requests.post(
103
  POST_URL,
104
  headers=HEADERS_POST,
 
107
  )
108
  res.raise_for_status()
109
 
110
+ # ===== SaveNow 用ユーティリティ =====
111
+ def build_savenow_initial_url(youtube_url, quality_opt, api_key=SAVENOW_API):
112
+ """
113
+ quality_opt:
114
+ - "a" -> mp3 (format=mp3)
115
+ - "f" -> omit format パラメータ(要求どおり 'f' は削除)
116
+ - numeric (例 "720") -> format=720
117
+ """
118
+ params = []
119
+ params.append("copyright=0")
120
+ # f は削除(つまり送らない)
121
+ if quality_opt == "a":
122
+ params.append("format=mp3")
123
+ elif quality_opt == "f":
124
+ # 指示通り 'f' パラメータは付けない(format自体を送らない)
125
+ pass
126
+ else:
127
+ # 数値系フォーマット
128
+ params.append(f"format={quote_plus(str(quality_opt))}")
129
+ params.append(f"url={quote_plus(youtube_url)}")
130
+ params.append(f"api={quote_plus(api_key)}")
131
+ return f"{SAVENOW_BASE}?{'&'.join(params)}"
132
+
133
+ def poll_progress_url(progress_url, max_attempts=30, interval=10):
134
+ """
135
+ progress_url をポーリングして download_url / alternative_download_urls を取得する。
136
+ interval は秒。max_attempts 回の試行を行う。
137
+ 成功時は (True, response_json) を返し、失敗(タイムアウト)は (False, last_json) を返す。
138
+ """
139
+ attempt = 0
140
+ last_json = None
141
+ while attempt < max_attempts:
142
+ try:
143
+ r = requests.get(progress_url, timeout=30)
144
+ r.raise_for_status()
145
+ data = r.json()
146
+ last_json = data
147
+ # 成功条件: download_url または alternative_download_urls が存在すれば成功とみなす
148
+ if data.get("download_url") or data.get("alternative_download_urls"):
149
+ return True, data
150
+ # 進捗表示などを channel に流しても良い
151
+ progress = data.get("progress")
152
+ status_text = data.get("text") or data.get("message") or ""
153
+ # ログとして出力(開発時用)
154
+ print(f"poll {attempt+1}/{max_attempts} progress={progress} text={status_text}")
155
+ except Exception as e:
156
+ print(f"poll error (attempt {attempt+1}): {e}")
157
+ # 失敗しても次へ(短時間の一時的なエラーを許容)
158
+ attempt += 1
159
+ time.sleep(interval)
160
+ return False, last_json
161
 
162
+ def process_savenow_flow(youtube_url, quality_opt):
163
+ """
164
+ SaveNow の初回 GET -> progress_url をポーリング -> 見つかったリンクを channel に送信
165
+ """
166
+ initial_url = build_savenow_initial_url(youtube_url, quality_opt)
167
+ send_to_channel(f"SaveNow にリクエストを送信しています: {initial_url}")
168
+ try:
169
+ r = requests.get(initial_url, timeout=30)
170
+ r.raise_for_status()
171
+ init_json = r.json()
172
+ except Exception as e:
173
+ send_to_channel(f"SaveNow へのリクエストに失敗しました: {e}")
174
+ return None
175
 
176
+ # 初回レスポンスに progress_url がある想定
177
+ progress_url = init_json.get("progress_url") or init_json.get("progressUrl") or init_json.get("progress")
178
+ # 一部の API は progress_url をそのまま返し、別のものは result に入れている場合があるため、念のため探索
179
+ if not progress_url:
180
+ # もし最初のレスポンスで直接 download_url があるなら即時処理
181
+ if init_json.get("download_url") or init_json.get("alternative_download_urls"):
182
+ # 直接リンクが来ている場合
183
+ urls = []
184
+ if init_json.get("download_url"):
185
+ urls.append(init_json["download_url"])
186
+ for alt in init_json.get("alternative_download_urls", []):
187
+ if alt.get("url"):
188
+ urls.append(alt["url"])
189
+ # channel に送る
190
+ for i, u in enumerate(urls, start=1):
191
+ send_to_channel(f"ダウンロードリンク #{i}: <link type=\"url\" value=\"{u}\">{u}</link>")
192
+ return urls
193
+ send_to_channel("progress_url が見つかりませんでした。レスポンスをログに出します。")
194
+ send_to_channel(f"初回レスポンス: {json.dumps(init_json, ensure_ascii=False)[:1000]}")
195
+ return None
196
 
197
+ # progress_url が��ればポーリング
198
+ send_to_channel(f"進捗監視を開始します: {progress_url} (最大30回、10秒間隔)")
199
+ ok, poll_json = poll_progress_url(progress_url, max_attempts=30, interval=10)
200
+ if not ok:
201
+ send_to_channel("進捗監視がタイムアウトしました。最後のレスポンスをログに出します。")
202
+ send_to_channel(f"最後のレスポンス: {json.dumps(poll_json, ensure_ascii=False)[:1000]}")
203
+ return None
204
 
205
+ # 成功した場合、download_url と alternative_download_urls を収集して channel に送信
206
+ urls = []
207
+ if poll_json.get("download_url"):
208
+ urls.append(poll_json["download_url"])
209
+ for alt in poll_json.get("alternative_download_urls", []):
210
+ if alt.get("url"):
211
+ urls.append(alt["url"])
212
+
213
+ # 重複排除
214
+ seen = set()
215
+ unique_urls = []
216
+ for u in urls:
217
+ if u and u not in seen:
218
+ seen.add(u)
219
+ unique_urls.append(u)
220
+
221
+ if not unique_urls:
222
+ send_to_channel("ダウンロードリンクが見つかりませんでした(成功レスポンスでしたが link が空)。")
223
+ return None
224
 
225
+ for i, u in enumerate(unique_urls, start=1):
226
+ send_to_channel(f"ダウンロードリンク #{i}: <link type=\"url\" value=\"{u}\">{u}</link>")
 
 
 
 
 
 
 
 
227
 
228
+ return unique_urls
 
 
 
 
 
 
229
 
230
+ # ===== Main (既存フローの中に組み込む想定) =====
231
  def main():
232
+ processed_messages = set()
 
233
  while True:
234
  try:
235
  res = requests.get(
 
251
 
252
  if not plain_text or updated_at is None:
253
  continue
 
 
254
  if msg_id in processed_messages:
255
  continue
256
 
 
268
 
269
  video_id, option = extract_youtube_and_option(latest_msg["plainText"])
270
  if not video_id:
271
+ # YouTube ID が取れないメッセージはスキップ
272
+ processed_messages.add(latest_msg["id"])
273
  time.sleep(10)
274
  continue
275
+
276
  valid_options = ["144", "240", "360", "480", "720", "1080", "1440", "2160", "4320", "4k", "8k", "2k", "a", "f"]
277
 
 
278
  if option is None:
279
  send_to_channel(
280
  "画質が指定されていないため、デフォルトの720pを使用します。\n"
281
  "指定する場合は、動画URLの後にスペースを入れて、"
282
+ "144/240/360/480/720/1080/2k(または1440)/4k(または2160)/8k(または4320)/a(音声/mp3)/f(最大画質・formatパラメータ未送信) のいずれかを入力してください。"
283
  )
284
  else:
285
  opt_lower = option.lower()
 
287
  send_to_channel(
288
  "不明な画質、または予期しない形式でした。\n"
289
  "対応画質: 144, 240, 360, 480, 720, 1080, "
290
+ "2k(1440), 4k(2160), 8k(4320), a(音声/mp3), f(最大画質)\n"
291
  "動画のURLの後にスペースを入れて指定してください。\n"
292
  "例: https://youtu.be/abcdEFGHijk 1080"
293
  )
 
295
  continue
296
 
297
  quality_opt = normalize_quality(option)
 
298
  youtube_url = f"https://www.youtube.com/watch?v={video_id}"
299
+ send_to_channel(f"{video_id} のダウンロード(SaveNow)を開始します。")
300
+
301
+ # SaveNow フローを使う(要求にあった処理)
302
+ urls = process_savenow_flow(youtube_url, quality_opt)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
 
304
+ if urls:
305
+ send_to_channel("リンクを取得しました。必要ならファイルをアップロードする処理を追加してください。")
306
+ else:
307
+ send_to_channel("リンクが取得できませんでした。別の方法を試すか、ログを確認してください。")
308
+
309
+ processed_messages.add(latest_msg["id"])
310
+ print(f"処理完了: {video_id}")
311
 
312
  except Exception as e:
313
  print("エラー:", e)