File size: 17,346 Bytes
259fab3
3f49d8c
259fab3
3f49d8c
2d4a386
259fab3
f87074b
259fab3
490219d
3f49d8c
 
 
259fab3
 
 
 
 
 
 
 
677e8ae
259fab3
3f49d8c
 
 
 
 
 
 
259fab3
 
3f49d8c
259fab3
 
 
505db77
 
265a509
3f49d8c
259fab3
 
 
490219d
259fab3
 
 
3f49d8c
dcf1807
505db77
0daba3a
505db77
3f49d8c
505db77
0daba3a
505db77
0daba3a
505db77
0daba3a
3f49d8c
 
 
 
 
 
cba51eb
c70897d
505db77
 
 
 
 
c70897d
505db77
c70897d
 
 
 
505db77
c70897d
 
 
505db77
 
 
c70897d
 
505db77
 
 
5f065af
 
 
 
 
505db77
c70897d
505db77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c70897d
505db77
c70897d
 
505db77
c70897d
505db77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0034130
505db77
 
 
0034130
505db77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f49d8c
 
 
490219d
fe97e6f
63326c9
fe97e6f
3f49d8c
505db77
2d4a386
 
 
 
490219d
2d4a386
 
3f49d8c
505db77
 
a77ba8e
505db77
 
eb2f4e1
a77ba8e
eb2f4e1
505db77
 
0034130
 
505db77
eb2f4e1
0034130
 
 
 
 
 
 
 
505db77
 
a77ba8e
eb2f4e1
 
505db77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eb2f4e1
505db77
259fab3
505db77
 
259fab3
 
fe97e6f
 
 
 
 
 
3f49d8c
490219d
3f49d8c
bc9968c
 
 
490219d
ec8aead
490219d
4f48744
 
bc9968c
490219d
505db77
 
ec8aead
490219d
bc9968c
 
 
ec8aead
bc9968c
 
 
 
 
 
 
 
 
c70897d
 
bc9968c
 
505db77
dfeb692
 
505db77
3a3ca6d
dfeb692
3a3ca6d
 
9527160
3b94571
3a3ca6d
 
 
 
 
 
505db77
3a3ca6d
9527160
3a3ca6d
 
 
 
 
505db77
c70897d
d27af8b
fd917dd
505db77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cba51eb
505db77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fd917dd
505db77
 
bc9968c
259fab3
bc9968c
 
 
 
 
259fab3
 
292c560
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
import os
import re
import time
import json
import requests
from datetime import datetime, timezone
from bs4 import BeautifulSoup

# ===== Channel.io 設定 =====
GET_URL = "https://desk-api.channel.io/desk/channels/200605/groups/519217/messages"
POST_URL = GET_URL

PARAMS = {
    "sortOrder": "desc",
    "limit": 36,
    "logFolded": "false",
}

X_ACCOUNT = os.getenv("channeliotokenbot2")
if not X_ACCOUNT:
    raise RuntimeError("環境変数 channeliotokenbot2 が設定されていません")

HEADERS_GET = {
    "accept": "application/json",
    "accept-language": "ja",
    "x-account": X_ACCOUNT,
}

HEADERS_POST = {
    "accept": "application/json",
    "accept-language": "ja",
    "content-type": "application/json",
    "x-account": X_ACCOUNT,
}

# ===== ssyoutube =====
SSYOUTUBE_URL = "https://ssyoutube.online/yt-video-detail/"

# ===== Utils =====
def parse_updated_at(value):
    if isinstance(value, (int, float)):
        return datetime.fromtimestamp(value / 1000, tz=timezone.utc)
    elif isinstance(value, str):
        return datetime.fromisoformat(value.replace("Z", "+00:00"))
    return None

def extract_youtube_id(text):
    patterns = [
        # クエリパラメータ v= または vi=
        r"(?:[?&](?:v|vi)=)([A-Za-z0-9_-]{11})",
        # youtu.be 短縮リンク
        r"youtu\.be/([A-Za-z0-9_-]{11})",
        # /embed/ /v/ /shorts/
        r"youtube(?:-nocookie)?\.com/(?:embed|v|shorts)/([A-Za-z0-9_-]{11})",
        # watch? の中に v= が含まれるケース(ioパラメータ等が混ざっても OK)
        r"youtube(?:-nocookie)?\.com/watch\?.*?v=([A-Za-z0-9_-]{11})",
        # hash や他パラメータに混ざるケース: ...#v=xxxx or &v=xxxx
        r"[#&]v=([A-Za-z0-9_-]{11})"
    ]
    for p in patterns:
        m = re.search(p, text)
        if m:
            return m.group(1)
    return None

def extract_youtube_and_option(text):
    """
    YouTube URL と画質指定オプションを抽出する
    戻り値: (video_id, option) 例: ("abcdEFGHijk", "720")
    """
    # 全角スペースも対象に
    parts = re.split(r"[ \u3000]+", text.strip())

    video_id = extract_youtube_id(parts[0])
    option = None
    if len(parts) > 1:
        option = parts[1].lower()

    return video_id, option

def normalize_quality(option):
    """
    ★ ユーザー入力を内部品質コードへ変換(全角も対応)
    """
    if option is None:
        return "720"

    # 全角 → 半角 に変換
    # 例: "720" → "720", "4k" → "4k"
    trans_table = str.maketrans(
        "0123456789kKpP",
        "0123456789kKpP"
    )
    option = option.translate(trans_table).lower()

    quality_map = {
        "144": "144",
        "240": "240",
        "360": "360",
        "480": "480",
        "720": "720",
        "1080": "1080",
        "2k": "1440",
        "1440": "1440",
        "4k": "2160",
        "2160": "2160",
        "8k": "4320",
        "4320": "4320",
        "144p": "144",
        "240p": "240",
        "360p": "360",
        "480p": "480",
        "720p": "720",
        "1080p": "1080",
        "2kp": "1440",
        "1440p": "1440",
        "4kp": "2160",
        "2160p": "2160",
        "8kp": "4320",
        "4320p": "4320",
    }

    if option in quality_map:
        return quality_map[option]

    if option in ["a", "f"]:
        return option  # 特殊指定

    return "720"  # デフォルト

def get_video_title(youtube_url):
    """YouTube動画のタイトルを取得する簡易的な関数"""
    try:
        res = requests.get(youtube_url, timeout=10)
        soup = BeautifulSoup(res.text, "lxml")
        title_tag = soup.find("meta", property="og:title")
        if title_tag:
            return title_tag.get("content", "").replace(" - YouTube", "")
    except:
        pass
    return "YouTube動画"

# ===== ssyoutube HTML 解析 =====
def fetch_download_links(youtube_url):
    res = requests.post(
        SSYOUTUBE_URL,
        data={"videoURL": youtube_url},
        timeout=20,
        headers={
            "User-Agent": "Mozilla/5.0",
            "Referer": "https://ssyoutube.online/",
        }
    )
    res.raise_for_status()
    
    # nonceを抽出
    soup = BeautifulSoup(res.text, "lxml")
    nonce_match = re.search(r"formData\.append\('nonce', '([^']+)'\);", res.text)
    nonce = nonce_match.group(1) if nonce_match else None
    
    buttons = soup.select("button[data-url]")

    results = []
    for btn in buttons:
        url = btn.get("data-url")
        quality = btn.get("data-quality")
        has_audio = btn.get("data-has-audio")

        if not url:
            continue

        results.append({
            "url": url,
            "quality": quality or "audio",
            "has_audio": has_audio,
        })

    return results, nonce

# ===== 最高画質映像 + 音声を選択 =====
def select_best_video_and_audio(items):
    video_items = []
    audio_items = []

    for item in items:
        if item["quality"] == "audio":
            audio_items.append(item)
        elif item["has_audio"] == "false":
            m = re.match(r"(\d+)p", item["quality"])
            if m:
                video_items.append((int(m.group(1)), item))

    if not video_items or not audio_items:
        return None, None

    # 最高画質の動画を選択
    video_items.sort(key=lambda x: x[0], reverse=True)
    best_video = video_items[0][1]
    
    # 最高音質の音声を選択(例: 128kbps > 64kbps)
    # 実際の音声品質情報に基づいて選択する場合は要修正
    best_audio = audio_items[0] if audio_items else None
    
    return best_video, best_audio

def select_quality_video(items, quality):
    """
    ★ 指定画質に最適な動画を選択(音声なし映像のみ)
    """
    video_candidates = []
    for item in items:
        if item["has_audio"] == "false":
            m = re.match(r"(\d+)p", item["quality"])
            if m:
                q = int(m.group(1))
                video_candidates.append((q, item))

    if not video_candidates:
        return None

    # 最大画質
    video_candidates.sort(key=lambda x: x[0])

    if quality == "f":  # 最大画質
        return video_candidates[-1][1]

    req = int(quality)
    best = None

    # 指定画質以上で一番近いもの
    for q, item in video_candidates:
        if q >= req:
            best = item
            break

    # 無ければ最大画質
    return best if best else video_candidates[-1][1]

# ===== ssyoutube サーバー側結合 =====
def merge_video_on_server(video_url, audio_url, video_id, quality, video_title, nonce):
    """
    ssyoutubeサーバーで動画と音声を結合する
    """
    # IDと画質からリクエストIDを生成
    request_id = f"{video_id}_{quality}p"
    
    # リクエストデータの構築
    request_data = {
        "id": request_id,
        "ttl": 3600000,
        "inputs": [
            {
                "url": video_url,
                "ext": "mp4",
                "chunkDownload": {
                    "type": "header",
                    "size": 52428800,
                    "concurrency": 3
                }
            },
            {
                "url": audio_url,
                "ext": "m4a"
            }
        ],
        "output": {
            "ext": "mp4",
            "downloadName": f"{video_title}_{quality}p.mp4",
            "chunkUpload": {
                "size": 104857600,
                "concurrency": 3
            }
        },
        "operation": {
            "type": "replace_audio_in_video"
        }
    }
    
    # 結合開始リクエスト
    payload = {
        "action": "process_video_merge",
        "nonce": nonce,
        "request_data": json.dumps(request_data)
    }
    
    headers = {
        "User-Agent": "Mozilla/5.0",
        "Referer": "https://ssyoutube.online/",
        "Origin": "https://ssyoutube.online",
        "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
    }
    
    res = requests.post(
        "https://ssyoutube.online/wp-admin/admin-ajax.php",
        data=payload,
        headers=headers,
        timeout=60
    )
    res.raise_for_status()
    
    result = res.json()
    
    if not result.get("success") or not result.get("data", {}).get("success"):
        raise RuntimeError(f"結合開始に失敗しました: {result}")
    
    # ステータス監視用URLを取得
    monitor_url = result["data"]["result"]["monitor"]["http"]
    
    # 完了するまでポーリング
    max_attempts = 5 # 最大60回試行(30秒間隔で約30分)
    attempt = 0
    
    while attempt < max_attempts:
        time.sleep(20)  # 30秒間隔でチェック
        
        status_res = requests.get(monitor_url, timeout=30)
        status_res.raise_for_status()
        status_data = status_res.json()
        
        if not status_data.get("success"):
            raise RuntimeError(f"ステータス取得に失敗しました: {status_data}")
        
        status = status_data["result"]["status"]
        
        if status == "done":
            # 完了したらダウンロードURLを返す
            return status_data["result"]["output"]["url"]
        elif status == "error":
            error_msg = status_data["result"]["error"]
            raise RuntimeError(f"結合処理でエラーが発生しました: {error_msg}")
        # "processing" の場合は継続
        
        attempt += 1
        print(f"結合処理中... 進捗: {status_data['result'].get('progress_in_percent', 0)}%")
    
    raise RuntimeError("結合処理がタイムアウトしました")

def send_to_channel(text):
    payload = {
        "requestId": f"desk-web-{int(time.time() * 1000)}",
        "blocks": [
            {"type": "text", "value": text}
        ],
    }

    res = requests.post(
        POST_URL,
        headers=HEADERS_POST,
        data=json.dumps(payload),
        timeout=30
    )
    res.raise_for_status()

    
def upload_file_to_channel(file_url):
    # mp4バイナリ取得
    file_res = requests.get(file_url, timeout=60)
    file_res.raise_for_status()

    upload_url = "https://media.channel.io/cht/v1/pri-file/200605/groups/519217/message/send_yt_video_file.mp4"

    headers = {
        "x-account": X_ACCOUNT,
        "Content-Type": "video/mp4",
        "Content-Length": str(len(file_res.content)),
    }


    res = requests.post(
        upload_url,
        headers=headers,
        data=file_res.content,
        timeout=60
    )

    res.raise_for_status()

    return res.json()  # ←これを messages POST の body.files に入れる


def send_video_message(file_json):
    request_id = f"desk-web-{int(time.time() * 1000)}"
    
    payload = {
        "requestId": request_id,
        "blocks": [
            {"type": "text", "value": "プレビュー:"}
        ],
        "files": [file_json],  # ←ここが重要!
    }

    res = requests.post(
        POST_URL,
        headers=HEADERS_POST,
        data=json.dumps(payload),
        timeout=30
    )
    res.raise_for_status()

# ===== Main =====
def main():
    processed_messages = set()  # 処理済みメッセージを追跡
    
    while True:
        try:
            res = requests.get(
                GET_URL,
                headers=HEADERS_GET,
                params=PARAMS,
                timeout=30,
            )
            res.raise_for_status()

            messages = res.json().get("messages", [])
            latest_msg = None
            latest_time = None

            for msg in messages:
                msg_id = msg.get("id")
                plain_text = msg.get("plainText")
                updated_at = msg.get("updatedAt")

                if not plain_text or updated_at is None:
                    continue
                    
                # 既に処理済みのメッセージはスキップ
                if msg_id in processed_messages:
                    continue

                t = parse_updated_at(updated_at)
                if not t:
                    continue

                if latest_time is None or t > latest_time:
                    latest_time = t
                    latest_msg = msg

            if not latest_msg:
                time.sleep(10)
                continue

            video_id, option = extract_youtube_and_option(latest_msg["plainText"])
            if not video_id:
                time.sleep(10)
                continue
                
            valid_options = ["144", "240", "360", "480", "720", "1080", "1440", "2160", "4320", "4k", "8k", "2k", "a", "f"]

            # 画質入力チェックを normalize_quality の前にする
            if option is None:
                send_to_channel(
                    "画質が指定されていないため、デフォルトの720pを使用します。\n"
                    "指定する場合は、動画URLの後にスペースを入れて、"
                    "144/240/360/480/720/1080/2k(または1440)/4k(または2160)/8k(または4320)/a(結合しない)/f(最高画質) のいずれかを入力してください。\n注意:最大画質や大きい画質、長い動画だと処理に時間がかかるからタイムアウトしやすいです。"
                )
            else:
                opt_lower = option.lower()
                if opt_lower not in valid_options:
                    send_to_channel(
                        "不明な画質、または予期しない形式でした。\n"
                        "対応画質: 144, 240, 360, 480, 720, 1080, "
                        "2k(1440), 4k(2160), 8k(4320), a(結合無効), f(最大画質)\n"
                        "動画のURLの後にスペースを入れて指定してください。\n"
                        "例: https://youtu.be/abcdEFGHijk 1080\n注意:最大画質や大きい画質、長い動画だと処理に時間がかかるからタイムアウトしやすいです。"
                    )
                    processed_messages.add(latest_msg["id"])
                    continue

            quality_opt = normalize_quality(option)

            youtube_url = f"https://www.youtube.com/watch?v={video_id}"
            send_to_channel(f"{video_id} のダウンロードを開始します。")

            video_title = get_video_title(youtube_url)
            items, nonce = fetch_download_links(youtube_url)

            all_items = items[:]
            audio_items = [i for i in items if i["quality"] == "audio"]

            selected_video = None
            selected_audio = audio_items[0] if audio_items else None

            if quality_opt not in ["a"]:
                selected_video = select_quality_video(items, quality_opt)

            # ダウンロードリスト送信
            message_lines = []
            message_lines.append(f"<b>{video_title}</b>\n")
            message_lines.append("<b>ダウンロードリンク</b>")
            
            # すべての動画リンクを追加
            for item in all_items:
                quality = item["quality"]
                if item["quality"] == "audio":
                    quality_display = "音声"
                elif item["has_audio"] == "true":
                    quality_display = f"{quality} (音声付き)"
                else:
                    quality_display = f"{quality} (映像のみ)"
                
                message_lines.append(f"<link type=\"url\" value=\"{item['url']}\"> {quality_display} </link>")

            message_lines.append("★動画ファイルと音声ファイルをダウンロードして<link type=\"url\" value=\"https://qooly.com/ja/merge-video-and-audio-online\">ここ</link>などで結合すると早くて確実です。")
            send_to_channel("\n".join(message_lines))
            
            # ★ 結合フラグ
            do_merge = quality_opt not in ["a"]

            if do_merge and selected_video and selected_audio:
                m = re.match(r"(\d+)p", selected_video["quality"])
                if m:
                    q = m.group(1)
                    send_to_channel(f"{q}pで音声との結合を開始しました。")
                    try:
                        merged_url = merge_video_on_server(
                            selected_video["url"],
                            selected_audio["url"],
                            video_id,
                            q,
                            video_title,
                            nonce
                        )
                        send_to_channel(f"<link type=\"url\" value=\"{merged_url}\">{q}p 結合済み動画</link>")
                        file_json = upload_file_to_channel(merged_url)
                        send_video_message(file_json)
                    except Exception as e:
                        send_to_channel(f"結合失敗: {e}")
            send_to_channel("完了しました!")
            processed_messages.add(latest_msg["id"])

            print(f"送信完了: {video_id}")

        except Exception as e:
            print("エラー:", e)
            import traceback
            traceback.print_exc()

        time.sleep(15)

if __name__ == "__main__":
    main()