izuemon commited on
Commit
505db77
·
verified ·
1 Parent(s): cba51eb

Update watcher.py

Browse files
Files changed (1) hide show
  1. watcher.py +358 -189
watcher.py CHANGED
@@ -5,7 +5,6 @@ import json
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,11 +33,8 @@ HEADERS_POST = {
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,10 +46,15 @@ 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:
@@ -61,76 +62,278 @@ def extract_youtube_id(text):
61
  if m:
62
  return m.group(1)
63
  return None
64
-
65
- def upload_file_to_channel(file_url):
66
- # mp4バイナリ取得
67
- file_res = requests.get(file_url, timeout=60)
68
- file_res.raise_for_status()
69
-
70
- upload_url = "https://media.channel.io/cht/v1/pri-file/200605/groups/519217/message/send_yt_video_file.mp4"
71
-
72
- files = {
73
- "file": ("video.mp4", file_res.content, "video/mp4")
74
- }
75
-
76
- headers = {
77
- "x-account": X_ACCOUNT,
78
- }
79
-
80
- res = requests.post(upload_url, headers=headers, files=files, timeout=60)
81
- res.raise_for_status()
82
-
83
- return res.json() # ←これを messages POST の body.files に入れる
84
-
85
 
86
- def send_video_message(file_json):
87
- request_id = f"desk-web-{int(time.time() * 1000)}"
88
-
89
- payload = {
90
- "requestId": request_id,
91
- "blocks": [
92
- {"type": "text", "value": "プレビュー:"}
93
- ],
94
- "files": [file_json], # ←ここが重要!
95
- }
96
-
97
- res = requests.post(
98
- POST_URL,
99
- headers=HEADERS_POST,
100
- data=json.dumps(payload),
101
- timeout=30
102
- )
103
- res.raise_for_status()
104
-
105
  def extract_youtube_and_option(text):
 
 
 
 
 
106
  parts = re.split(r"[ \u3000]+", text.strip())
 
107
  video_id = extract_youtube_id(parts[0])
108
  option = None
109
  if len(parts) > 1:
110
  option = parts[1].lower()
 
111
  return video_id, option
112
 
113
  def normalize_quality(option):
 
 
 
114
  if option is None:
115
  return "720"
 
 
 
116
  trans_table = str.maketrans(
117
  "0123456789kKpP",
118
  "0123456789kKpP"
119
  )
120
  option = option.translate(trans_table).lower()
 
121
  quality_map = {
122
- "144": "144", "240": "240", "360": "360", "480": "480",
123
- "720": "720", "1080": "1080", "2k": "1440", "1440": "1440",
124
- "4k": "2160", "2160": "2160", "8k": "4320", "4320": "4320",
125
- "144p": "144", "240p": "240", "360p": "360", "480p": "480",
126
- "720p": "720", "1080p": "1080", "2kp": "1440", "1440p": "1440",
127
- "4kp": "2160", "2160p": "2160", "8kp": "4320", "4320p": "4320",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  }
 
129
  if option in quality_map:
130
  return quality_map[option]
 
131
  if option in ["a", "f"]:
132
- return option
133
- return "720"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
 
135
  def send_to_channel(text):
136
  payload = {
@@ -139,6 +342,7 @@ def send_to_channel(text):
139
  {"type": "text", "value": text}
140
  ],
141
  }
 
142
  res = requests.post(
143
  POST_URL,
144
  headers=HEADERS_POST,
@@ -147,126 +351,51 @@ def send_to_channel(text):
147
  )
148
  res.raise_for_status()
149
 
150
- # ===== SaveNow 用ユーティリティ =====
151
- def build_savenow_initial_url(youtube_url, quality_opt, api_key=SAVENOW_API):
152
- """
153
- quality_opt:
154
- - "a" -> mp3 (format=mp3)
155
- - "f" -> omit format パラメータ(要求どおり 'f' は削除)
156
- - numeric (例 "720") -> format=720
157
- """
158
- params = []
159
- params.append("copyright=0")
160
- # f は削除(つまり送らない)
161
- if quality_opt == "a":
162
- params.append("format=mp3")
163
- elif quality_opt == "f":
164
- # 指示通り 'f' パラメータは付けない(format自体を送らない)
165
- pass
166
- else:
167
- # 数値系フォーマット
168
- params.append(f"format={quote_plus(str(quality_opt))}")
169
- params.append(f"url={quote_plus(youtube_url)}")
170
- params.append(f"api={quote_plus(api_key)}")
171
- return f"{SAVENOW_BASE}?{'&'.join(params)}"
172
-
173
- def poll_progress_url(progress_url, max_attempts=30, interval=10):
174
- """
175
- progress_url をポーリングして download_url / alternative_download_urls を取得する。
176
- interval は秒。max_attempts 回の試行を行う。
177
- 成功時は (True, response_json) を返し、失敗(タイムアウト)は (False, last_json) を返す。
178
- """
179
- attempt = 0
180
- last_json = None
181
- while attempt < max_attempts:
182
- try:
183
- r = requests.get(progress_url, timeout=30)
184
- r.raise_for_status()
185
- data = r.json()
186
- last_json = data
187
- # 成功条件: download_url または alternative_download_urls が存在すれば成功とみなす
188
- if data.get("download_url") or data.get("alternative_download_urls"):
189
- return True, data
190
- # 進捗表示などを channel に流しても良い
191
- progress = data.get("progress")
192
- status_text = data.get("text") or data.get("message") or ""
193
- # ログとして出力(開発時用)
194
- print(f"poll {attempt+1}/{max_attempts} progress={progress} text={status_text}")
195
- except Exception as e:
196
- print(f"poll error (attempt {attempt+1}): {e}")
197
- # 失敗しても次へ(短時間の一時的なエラーを許容)
198
- attempt += 1
199
- time.sleep(interval)
200
- return False, last_json
201
 
202
- def process_savenow_flow(youtube_url, quality_opt):
203
- initial_url = build_savenow_initial_url(youtube_url, quality_opt)
204
- send_to_channel(f"リクエストを送信しています")
205
- try:
206
- r = requests.get(initial_url, timeout=30)
207
- r.raise_for_status()
208
- init_json = r.json()
209
- except Exception as e:
210
- send_to_channel(f"リクエストに失敗しました: {e}")
211
- return None
212
 
213
- # 初回レスポンスに progress_url がある想定
214
- progress_url = init_json.get("progress_url") or init_json.get("progressUrl") or init_json.get("progress")
215
- # 一部の API は progress_url をそのまま返し、別のものは result に入れている場合があるため、念のため探索
216
- if not progress_url:
217
- # もし最初のレスポンスで直接 download_url があるなら即時処理
218
- if init_json.get("download_url") or init_json.get("alternative_download_urls"):
219
- # 直接リンクが来ている場合
220
- urls = []
221
- if init_json.get("download_url"):
222
- urls.append(init_json["download_url"])
223
- for alt in init_json.get("alternative_download_urls", []):
224
- if alt.get("url"):
225
- urls.append(alt["url"])
226
- # channel に送る
227
- for i, u in enumerate(urls, start=1):
228
- send_to_channel(f"<link type=\"url\" value=\"{u}\">ダウンロードリンク{i}</link>")
229
- return urls
230
- send_to_channel("progress_url が見つかりませんでした。")
231
- #send_to_channel(f"初回レスポンス: {json.dumps(init_json, ensure_ascii=False)[:1000]}")
232
- return None
233
 
234
- # progress_url があればポーリング
235
- send_to_channel(f"変換処理を開始します")
236
- ok, poll_json = poll_progress_url(progress_url, max_attempts=30, interval=10)
237
- if not ok:
238
- send_to_channel("進捗監視がタイムアウトしました。")
239
- send_to_channel(f"最後のレスポンス: {json.dumps(poll_json, ensure_ascii=False)[:1000]}")
240
- return None
241
 
242
- # 成功した場合、download_url alternative_download_urls を収集して channel に送信
243
- urls = []
244
- if poll_json.get("download_url"):
245
- urls.append(poll_json["download_url"])
246
- for alt in poll_json.get("alternative_download_urls", []):
247
- if alt.get("url"):
248
- urls.append(alt["url"])
249
-
250
- # 重複排除
251
- seen = set()
252
- unique_urls = []
253
- for u in urls:
254
- if u and u not in seen:
255
- seen.add(u)
256
- unique_urls.append(u)
257
-
258
- if not unique_urls:
259
- send_to_channel("ダウンロードリンクが見つかりませんでした(成功レスポンスでしたがlinkが空)。")
260
- return None
261
 
262
- for i, u in enumerate(unique_urls, start=1):
263
- send_to_channel(f"<link type=\"url\" value=\"{u}\">ダウンロードリンク{i}</link>")
264
 
265
- return unique_urls
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
 
267
- # ===== Main (既存フローの中に組み込む想定) =====
268
  def main():
269
- processed_messages = set()
 
270
  while True:
271
  try:
272
  res = requests.get(
@@ -282,15 +411,14 @@ def main():
282
  latest_time = None
283
 
284
  for msg in messages:
285
- if msg.get("personId") == "595702":
286
- continue
287
-
288
  msg_id = msg.get("id")
289
  plain_text = msg.get("plainText")
290
  updated_at = msg.get("updatedAt")
291
 
292
  if not plain_text or updated_at is None:
293
  continue
 
 
294
  if msg_id in processed_messages:
295
  continue
296
 
@@ -308,18 +436,17 @@ def main():
308
 
309
  video_id, option = extract_youtube_and_option(latest_msg["plainText"])
310
  if not video_id:
311
- # YouTube ID が取れないメッセージはスキップ
312
- processed_messages.add(latest_msg["id"])
313
  time.sleep(10)
314
  continue
315
-
316
  valid_options = ["144", "240", "360", "480", "720", "1080", "1440", "2160", "4320", "4k", "8k", "2k", "a", "f"]
317
 
 
318
  if option is None:
319
  send_to_channel(
320
  "画質が指定されていないため、デフォルトの720pを使用します。\n"
321
  "指定する場合は、動画URLの後にスペースを入れて、"
322
- "144/240/360/480/720/1080/2k(または1440)/4k(または2160)/8k(または4320)/a(音声/mp3)/f(最大画質・formatパラメータ未送信) のいずれかを入力してください。"
323
  )
324
  else:
325
  opt_lower = option.lower()
@@ -327,7 +454,7 @@ def main():
327
  send_to_channel(
328
  "不明な画質、または予期しない形式でした。\n"
329
  "対応画質: 144, 240, 360, 480, 720, 1080, "
330
- "2k(1440), 4k(2160), 8k(4320), a(音声/mp3), f(最大画質)\n"
331
  "動画のURLの後にスペースを入れて指定してください。\n"
332
  "例: https://youtu.be/abcdEFGHijk 1080"
333
  )
@@ -335,26 +462,68 @@ def main():
335
  continue
336
 
337
  quality_opt = normalize_quality(option)
 
338
  youtube_url = f"https://www.youtube.com/watch?v={video_id}"
339
  send_to_channel(f"{video_id} のダウンロードを開始します。")
340
 
341
- # SaveNow フローを使う(要求にあった処理)
342
- urls = process_savenow_flow(youtube_url, quality_opt)
343
-
344
- if not urls:
345
- send_to_channel("リンクが取得できませんでした。別の方法を試すか、ログを確認してください。")
346
- merged_url = urls[0]
347
-
348
- send_to_channel("ファイルをダウンロードして送信します。少々お待ちください。")
349
-
350
- try:
351
- file_json = upload_file_to_channel(merged_url)
352
- send_video_message(file_json)
353
- except Exception as e:
354
- send_to_channel(f"ファイル送信中にエラーが発生しました: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
355
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  processed_messages.add(latest_msg["id"])
357
- print(f"処理完了: {video_id}")
 
358
 
359
  except Exception as e:
360
  print("エラー:", e)
 
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
  "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
 
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:
 
62
  if m:
63
  return m.group(1)
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
  {"type": "text", "value": text}
343
  ],
344
  }
345
+
346
  res = requests.post(
347
  POST_URL,
348
  headers=HEADERS_POST,
 
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(
 
411
  latest_time = None
412
 
413
  for msg in messages:
 
 
 
414
  msg_id = msg.get("id")
415
  plain_text = msg.get("plainText")
416
  updated_at = msg.get("updatedAt")
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
 
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
  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
  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)