ljx77qaq commited on
Commit
31dfd00
·
verified ·
1 Parent(s): 1784a49

支持实况图(live图):抖音/小红书 live 提取、相册内图视频混发、Web 端可播放可下载

Browse files
Files changed (6) hide show
  1. app.py +10 -0
  2. big_send.py +16 -21
  3. bot.py +155 -60
  4. douyin_extract.py +31 -7
  5. static/index.html +52 -6
  6. xhs_extract.py +40 -8
app.py CHANGED
@@ -55,6 +55,16 @@ def api_parse():
55
 
56
  images = data.get("images") or []
57
  data["images_proxy"] = [_proxy_url(u, data.get("platform", "")) for u in images]
 
 
 
 
 
 
 
 
 
 
58
  if data.get("video_url"):
59
  data["video_proxy"] = _proxy_url(data["video_url"], data.get("platform", ""))
60
  if data.get("cover"):
 
55
 
56
  images = data.get("images") or []
57
  data["images_proxy"] = [_proxy_url(u, data.get("platform", "")) for u in images]
58
+
59
+ # 实况图(live 图):和 images 下标一一对应,非实况位置是空串
60
+ lives = list(data.get("lives") or [])
61
+ lives += [""] * max(0, len(images) - len(lives))
62
+ data["lives"] = lives
63
+ data["lives_proxy"] = [
64
+ _proxy_url(u, data.get("platform", "")) if u else "" for u in lives
65
+ ]
66
+ data["live_count"] = sum(1 for u in lives if u)
67
+
68
  if data.get("video_url"):
69
  data["video_proxy"] = _proxy_url(data["video_url"], data.get("platform", ""))
70
  if data.get("cover"):
big_send.py CHANGED
@@ -55,29 +55,19 @@ async def _send_video(chat_id, path, caption, duration, width, height,
55
  )
56
 
57
 
58
- async def _send_photos(chat_id, photos, caption):
59
- """发送 URL"""
60
- from pyrogram.types import InputMediaPhoto
61
  async with _get_client() as app:
62
  media = []
63
- for i, u in enumerate(photos[:10]):
 
64
  if i == 0 and caption:
65
- media.append(InputMediaPhoto(u, caption=caption, parse_mode=ParseMode.HTML))
 
 
66
  else:
67
- media.append(InputMediaPhoto(u))
68
- await app.send_media_group(chat_id, media)
69
-
70
-
71
- async def _send_local_photos(chat_id, paths, caption):
72
- """发送本地图片文件"""
73
- from pyrogram.types import InputMediaPhoto
74
- async with _get_client() as app:
75
- media = []
76
- for i, p in enumerate(paths[:10]):
77
- if i == 0 and caption:
78
- media.append(InputMediaPhoto(p, caption=caption, parse_mode=ParseMode.HTML))
79
- else:
80
- media.append(InputMediaPhoto(p))
81
  await app.send_media_group(chat_id, media)
82
 
83
 
@@ -87,11 +77,16 @@ def send_big_video(chat_id, path, caption="", duration=0, width=0, height=0,
87
  thumbnail, reply_markup))
88
 
89
 
 
 
 
 
 
90
  def send_photos_mtproto(chat_id, photos, caption=""):
91
  """发送 URL 图集(通过 mtproto)"""
92
- asyncio.run(_send_photos(chat_id, photos, caption))
93
 
94
 
95
  def send_local_photos_mtproto(chat_id, paths, caption=""):
96
  """发送本地图片文件(通过 mtproto)"""
97
- asyncio.run(_send_local_photos(chat_id, paths, caption))
 
55
  )
56
 
57
 
58
+ async def _send_album(chat_id, specs, caption):
59
+ """specs = [("photo"|"video", 地址或本地路径)],和实况视频可混发"""
60
+ from pyrogram.types import InputMediaPhoto, InputMediaVideo
61
  async with _get_client() as app:
62
  media = []
63
+ for i, (kind, src) in enumerate(specs[:10]):
64
+ kw = {}
65
  if i == 0 and caption:
66
+ kw = {"caption": caption, "parse_mode": ParseMode.HTML}
67
+ if kind == "video":
68
+ media.append(InputMediaVideo(src, supports_streaming=True, **kw))
69
  else:
70
+ media.append(InputMediaPhoto(src, **kw))
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  await app.send_media_group(chat_id, media)
72
 
73
 
 
77
  thumbnail, reply_markup))
78
 
79
 
80
+ def send_album_mtproto(chat_id, specs, caption=""):
81
+ """发送混合相册(图片 + 实况视频),通过 mtproto"""
82
+ asyncio.run(_send_album(chat_id, specs, caption))
83
+
84
+
85
  def send_photos_mtproto(chat_id, photos, caption=""):
86
  """发送 URL 图集(通过 mtproto)"""
87
+ send_album_mtproto(chat_id, [("photo", u) for u in photos], caption)
88
 
89
 
90
  def send_local_photos_mtproto(chat_id, paths, caption=""):
91
  """发送本地图片文件(通过 mtproto)"""
92
+ send_album_mtproto(chat_id, [("photo", p) for p in paths], caption)
bot.py CHANGED
@@ -20,7 +20,8 @@ import yt_dlp
20
  from xhs_extract import extract_xhs
21
  from douyin_extract import extract_douyin
22
  try:
23
- from big_send import send_big_video, send_photos_mtproto, send_local_photos_mtproto
 
24
  except Exception as _e: # 未装 pyrogram / 未配 TG_API_ID 时也能跑 Web 前端
25
  print("big_send 不可用(大文件与 mtproto 兜底关闭):", _e)
26
 
@@ -28,6 +29,7 @@ except Exception as _e: # 未装 pyrogram / 未配 TG_API_ID 时也能跑 Web
28
  raise RuntimeError("mtproto 未启用:请安装 pyrofork 并配置 TG_API_ID / TG_API_HASH")
29
 
30
  send_big_video = send_photos_mtproto = send_local_photos_mtproto = _mtproto_off
 
31
 
32
  # ========== 反代 ==========
33
  apihelper.API_URL = "https://bot.nine7.cc.cd/bot{0}/{1}"
@@ -45,6 +47,14 @@ BOT_API_LIMIT = 50 * 1024 * 1024
45
  MTPROTO_LIMIT = 2000 * 1024 * 1024
46
  MAX_IMAGES = 30 # 图文最多发多少张(Telegram 每组 10 张,自动分组)
47
 
 
 
 
 
 
 
 
 
48
  # 没有 token 也允许启动(只跑 Web 前端)
49
  bot = telebot.TeleBot(BOT_TOKEN or "0:disabled", parse_mode="HTML")
50
 
@@ -444,33 +454,65 @@ def _download_direct(url: str, workdir: str, referer: str = "") -> str:
444
  return fp
445
 
446
  # ========== 图片批量下载(抖音等国内 CDN 需先下载) ==========
447
- def _download_images(urls: list, workdir: str, headers: dict = None) -> list:
448
- """下载图片到本地,返回本地路径列表"""
449
- if headers is None:
450
- headers = {
451
- "User-Agent": DOUYIN_HEADERS["User-Agent"],
452
- "Referer": "https://www.douyin.com/",
453
- }
454
- paths = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
455
  for i, url in enumerate(urls[:MAX_IMAGES]):
456
- try:
457
- ext = ".jpg"
458
- for e in (".webp", ".png", ".jpeg"):
459
- if e in url.lower():
460
- ext = e
461
- break
462
- fp = os.path.join(workdir, f"img_{i}{ext}")
463
- with requests.get(url, headers=headers, stream=True, timeout=30) as r:
464
- r.raise_for_status()
465
- with open(fp, "wb") as f:
466
- for chunk in r.iter_content(1024 * 64):
467
- if chunk:
468
- f.write(chunk)
469
- if os.path.exists(fp) and os.path.getsize(fp) > 0:
470
- paths.append(fp)
471
- except Exception as e:
472
- print(f"download image {i} failed:", e)
473
- return paths
 
 
 
 
 
 
474
 
475
  # ========== 只 remux 不转码 ==========
476
  def _ensure_h264_aac(path: str) -> str:
@@ -712,31 +754,59 @@ def _img_caption_limit(images):
712
  return CAPTION_LIMIT if len(images) <= 10 else TEXT_LIMIT - 64
713
 
714
 
715
- def _send_images(chat_id, images, caption, local_files=False, buttons=None):
716
  """
717
- 发送图集。Telegram 一组最多 10 张,超过就自动分组发送
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
718
  (抖音图文经常有 13~35 张,原来直接被截断到 10 张)。
719
 
 
 
720
  文案位置:
721
- · 只有一组(≤10 )—— 文案直接作为图集 caption;
722
  · 多组 —— 图先全部发完,文案 / 作者 / 原链接作为最后一条消息单独发,
723
  免得刷了几十张图之后还要往上翻才能看到文案和原链接。
724
  """
725
  if not images:
726
  return
727
- chunks = [images[i:i + 10] for i in range(0, len(images), 10)]
 
 
728
  inline = len(chunks) == 1 and len(caption or "") <= CAPTION_LIMIT
729
  for ch in chunks:
730
  _send_images_chunk(chat_id, ch, caption if inline else "", local_files)
731
  if not inline:
732
- _send_caption_message(chat_id, caption, len(images))
733
 
734
 
735
- def _send_caption_message(chat_id, caption, total=0):
736
  """图集发完后,把文案 / 作者 / 原链接作为最后一条消息发出去。"""
737
  if not caption:
738
  return
739
- text = f"🖼 共 {total} 张\n{caption}" if total else caption
 
 
 
740
  text = text[:TEXT_LIMIT]
741
  try:
742
  bot.send_message(chat_id, text, parse_mode="HTML",
@@ -750,23 +820,34 @@ def _send_caption_message(chat_id, caption, total=0):
750
  print("send caption plain text also failed:", e2)
751
 
752
 
753
- def _send_images_chunk(chat_id, images, caption, local_files=False):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
754
  if local_files:
755
  # ---- 本地文件:先尝试 Bot API,失败则走 mtproto ----
756
  files = []
757
  try:
758
  media = []
759
- for i, fp in enumerate(images):
760
  f = open(fp, "rb")
761
  files.append(f)
762
- inp = telebot.types.InputMediaPhoto(f)
763
- if i == 0 and caption:
764
- inp.caption = caption
765
- inp.parse_mode = "HTML"
766
- media.append(inp)
767
- bot.send_media_group(chat_id, media, timeout=120)
768
  except Exception as e:
769
- print("bot api send local images failed, fallback mtproto:", e)
770
  # 关闭已打开的句柄
771
  for f in files:
772
  try:
@@ -775,9 +856,9 @@ def _send_images_chunk(chat_id, images, caption, local_files=False):
775
  pass
776
  files.clear()
777
  try:
778
- send_local_photos_mtproto(chat_id, images, caption)
779
  except Exception as e2:
780
- print("mtproto send local photos also failed:", e2)
781
  finally:
782
  for f in files:
783
  try:
@@ -787,14 +868,12 @@ def _send_images_chunk(chat_id, images, caption, local_files=False):
787
  else:
788
  # ---- URL 列表 ----
789
  try:
790
- media = [telebot.types.InputMediaPhoto(u) for u in images]
791
- if caption:
792
- media[0].caption = caption
793
- media[0].parse_mode = "HTML"
794
- bot.send_media_group(chat_id, media, timeout=120)
795
  except Exception as e:
796
  print("bot api send_media_group failed, fallback mtproto:", e)
797
- send_photos_mtproto(chat_id, images, caption)
798
 
799
  # ========== 命令 ==========
800
  @bot.message_handler(commands=["start", "help"])
@@ -989,21 +1068,35 @@ def _process_link(chat_id, from_user, url, original_msg_id):
989
  platform=platform,
990
  limit=_img_caption_limit(data["images"]),
991
  )
992
- try:
993
- _send_images(chat_id, data["images"], cap)
994
- except Exception as e:
995
- # URL 直发失败(CDN 防盗链)时,落地到本地再发
996
- print("xhs url images failed, download locally:", e)
997
- local_paths = _download_images(
998
  data["images"], tmp,
999
  headers={
1000
  "User-Agent": XHS_HEADERS_UA["User-Agent"],
1001
  "Referer": "https://www.xiaohongshu.com/",
1002
  },
 
1003
  )
1004
  if not local_paths:
1005
  raise RuntimeError("小红书图片全部下载失败")
1006
- _send_images(chat_id, local_paths, cap, local_files=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1007
  try:
1008
  if status:
1009
  bot.delete_message(status.chat.id, status.message_id)
@@ -1028,12 +1121,13 @@ def _process_link(chat_id, from_user, url, original_msg_id):
1028
  if data and data.get("type") == "image" and data.get("images"):
1029
  # ★ 抖音图片 CDN 需要先下载到本地再发送
1030
  _update_status(status, "🖼 正在下载图片...")
1031
- local_paths = _download_images(
1032
  data["images"], tmp,
1033
  headers={
1034
  "User-Agent": DOUYIN_HEADERS["User-Agent"],
1035
  "Referer": "https://www.douyin.com/",
1036
  },
 
1037
  )
1038
  if local_paths:
1039
  _update_status(status, "📤 正在发送图集...")
@@ -1048,7 +1142,8 @@ def _process_link(chat_id, from_user, url, original_msg_id):
1048
  sender, url, platform=platform,
1049
  limit=_img_caption_limit(local_paths),
1050
  )
1051
- _send_images(chat_id, local_paths, cap, local_files=True)
 
1052
  else:
1053
  raise RuntimeError("抖音图片全部下载失败")
1054
  try:
 
20
  from xhs_extract import extract_xhs
21
  from douyin_extract import extract_douyin
22
  try:
23
+ from big_send import (send_big_video, send_photos_mtproto,
24
+ send_local_photos_mtproto, send_album_mtproto)
25
  except Exception as _e: # 未装 pyrogram / 未配 TG_API_ID 时也能跑 Web 前端
26
  print("big_send 不可用(大文件与 mtproto 兜底关闭):", _e)
27
 
 
29
  raise RuntimeError("mtproto 未启用:请安装 pyrofork 并配置 TG_API_ID / TG_API_HASH")
30
 
31
  send_big_video = send_photos_mtproto = send_local_photos_mtproto = _mtproto_off
32
+ send_album_mtproto = _mtproto_off
33
 
34
  # ========== 反代 ==========
35
  apihelper.API_URL = "https://bot.nine7.cc.cd/bot{0}/{1}"
 
47
  MTPROTO_LIMIT = 2000 * 1024 * 1024
48
  MAX_IMAGES = 30 # 图文最多发多少张(Telegram 每组 10 张,自动分组)
49
 
50
+ # 实况图(live 图)处理方式:
51
+ # video = 实况那张发成会动的视频(默认,Telegram 相册里图和视频可以混着发)
52
+ # both = 静态图和实况视频都发
53
+ # off = 忽略实况,只发静态图
54
+ LIVE_PHOTO_MODE = (os.environ.get("LIVE_PHOTO_MODE") or "video").strip().lower()
55
+ if LIVE_PHOTO_MODE not in ("video", "both", "off"):
56
+ LIVE_PHOTO_MODE = "video"
57
+
58
  # 没有 token 也允许启动(只跑 Web 前端)
59
  bot = telebot.TeleBot(BOT_TOKEN or "0:disabled", parse_mode="HTML")
60
 
 
454
  return fp
455
 
456
  # ========== 图片批量下载(抖音等国内 CDN 需先下载) ==========
457
+ def _default_img_headers():
458
+ return {
459
+ "User-Agent": DOUYIN_HEADERS["User-Agent"],
460
+ "Referer": "https://www.douyin.com/",
461
+ }
462
+
463
+
464
+ def _download_one(url: str, fp: str, headers: dict) -> str:
465
+ """下载单个文件,成功返回本地路径,失败返回空字符串"""
466
+ try:
467
+ with requests.get(url, headers=headers, stream=True, timeout=60) as r:
468
+ r.raise_for_status()
469
+ with open(fp, "wb") as f:
470
+ for chunk in r.iter_content(1024 * 64):
471
+ if chunk:
472
+ f.write(chunk)
473
+ if os.path.exists(fp) and os.path.getsize(fp) > 0:
474
+ return fp
475
+ except Exception as e:
476
+ print(f"download failed {url[:80]}:", e)
477
+ return ""
478
+
479
+
480
+ def _download_gallery(urls: list, workdir: str, headers: dict = None,
481
+ lives: list = None):
482
+ """
483
+ 下载图集到本地。返回 (图片路径列表, live 视频路径列表),两个列表严格等长、
484
+ 下标对应;某张图下载失败就整项跳过,不会让 live 和图片错位。
485
+ """
486
+ headers = headers or _default_img_headers()
487
+ lives = list(lives or [])
488
+ lives += [""] * max(0, len(urls) - len(lives))
489
+
490
+ paths, live_paths = [], []
491
  for i, url in enumerate(urls[:MAX_IMAGES]):
492
+ ext = ".jpg"
493
+ for e in (".webp", ".png", ".jpeg"):
494
+ if e in url.lower():
495
+ ext = e
496
+ break
497
+ fp = _download_one(url, os.path.join(workdir, f"img_{i}{ext}"), headers)
498
+ if not fp:
499
+ continue
500
+ lp = ""
501
+ if LIVE_PHOTO_MODE != "off" and lives[i]:
502
+ lp = _download_one(lives[i], os.path.join(workdir, f"live_{i}.mp4"),
503
+ headers)
504
+ paths.append(fp)
505
+ live_paths.append(lp)
506
+
507
+ if any(live_paths):
508
+ print(f"[gallery] {len(paths)} 张,其中实况 "
509
+ f"{sum(1 for x in live_paths if x)} 个")
510
+ return paths, live_paths
511
+
512
+
513
+ def _download_images(urls: list, workdir: str, headers: dict = None) -> list:
514
+ """下载图片到本地,返回本地路径列表(不含 live)"""
515
+ return _download_gallery(urls, workdir, headers)[0]
516
 
517
  # ========== 只 remux 不转码 ==========
518
  def _ensure_h264_aac(path: str) -> str:
 
754
  return CAPTION_LIMIT if len(images) <= 10 else TEXT_LIMIT - 64
755
 
756
 
757
+ def _build_media_specs(images, lives=None):
758
  """
759
+ 图集摊平成 [(类型, 地址)] —— 类型是 "photo" 或 "video"。
760
+ 实况图按 LIVE_PHOTO_MODE 决定发视频、图+视频、还是只发静态图。
761
+ """
762
+ lives = list(lives or [])
763
+ lives += [""] * max(0, len(images) - len(lives))
764
+ specs = []
765
+ for i, im in enumerate(images):
766
+ lv = lives[i] if LIVE_PHOTO_MODE != "off" else ""
767
+ if lv and LIVE_PHOTO_MODE == "video":
768
+ specs.append(("video", lv))
769
+ elif lv: # both
770
+ specs.append(("photo", im))
771
+ specs.append(("video", lv))
772
+ else:
773
+ specs.append(("photo", im))
774
+ return specs
775
+
776
+
777
+ def _send_images(chat_id, images, caption, local_files=False, buttons=None,
778
+ lives=None):
779
+ """
780
+ 发送图集。Telegram 一组最多 10 项,超过就自动分组发送
781
  (抖音图文经常有 13~35 张,原来直接被截断到 10 张)。
782
 
783
+ 实况图(live 图)会发成视频,和静态图混在同一个相册里,顺序不变。
784
+
785
  文案位置:
786
+ · 只有一组(≤10 )—— 文案直接作为图集 caption;
787
  · 多组 —— 图先全部发完,文案 / 作者 / 原链接作为最后一条消息单独发,
788
  免得刷了几十张图之后还要往上翻才能看到文案和原链接。
789
  """
790
  if not images:
791
  return
792
+ specs = _build_media_specs(images, lives)
793
+ live_n = sum(1 for k, _ in specs if k == "video")
794
+ chunks = [specs[i:i + 10] for i in range(0, len(specs), 10)]
795
  inline = len(chunks) == 1 and len(caption or "") <= CAPTION_LIMIT
796
  for ch in chunks:
797
  _send_images_chunk(chat_id, ch, caption if inline else "", local_files)
798
  if not inline:
799
+ _send_caption_message(chat_id, caption, len(images), live_n)
800
 
801
 
802
+ def _send_caption_message(chat_id, caption, total=0, live_n=0):
803
  """图集发完后,把文案 / 作者 / 原链接作为最后一条消息发出去。"""
804
  if not caption:
805
  return
806
+ head = f"🖼 共 {total} 张" if total else ""
807
+ if head and live_n:
808
+ head += f"(含 {live_n} 个实况 🎞)"
809
+ text = f"{head}\n{caption}" if head else caption
810
  text = text[:TEXT_LIMIT]
811
  try:
812
  bot.send_message(chat_id, text, parse_mode="HTML",
 
820
  print("send caption plain text also failed:", e2)
821
 
822
 
823
+ def _make_input_media(kind, src, caption=""):
824
+ if kind == "video":
825
+ inp = telebot.types.InputMediaVideo(src, supports_streaming=True)
826
+ else:
827
+ inp = telebot.types.InputMediaPhoto(src)
828
+ if caption:
829
+ inp.caption = caption
830
+ inp.parse_mode = "HTML"
831
+ return inp
832
+
833
+
834
+ def _send_images_chunk(chat_id, specs, caption, local_files=False):
835
+ """specs = [(\"photo\"|\"video\", 地址)],一次最多 10 项"""
836
+ if not specs:
837
+ return
838
  if local_files:
839
  # ---- 本地文件:先尝试 Bot API,失败则走 mtproto ----
840
  files = []
841
  try:
842
  media = []
843
+ for i, (kind, fp) in enumerate(specs):
844
  f = open(fp, "rb")
845
  files.append(f)
846
+ media.append(_make_input_media(kind, f,
847
+ caption if i == 0 else ""))
848
+ bot.send_media_group(chat_id, media, timeout=180)
 
 
 
849
  except Exception as e:
850
+ print("bot api send local media failed, fallback mtproto:", e)
851
  # 关闭已打开的句柄
852
  for f in files:
853
  try:
 
856
  pass
857
  files.clear()
858
  try:
859
+ send_album_mtproto(chat_id, specs, caption)
860
  except Exception as e2:
861
+ print("mtproto send local album also failed:", e2)
862
  finally:
863
  for f in files:
864
  try:
 
868
  else:
869
  # ---- URL 列表 ----
870
  try:
871
+ media = [_make_input_media(k, u, caption if i == 0 else "")
872
+ for i, (k, u) in enumerate(specs)]
873
+ bot.send_media_group(chat_id, media, timeout=180)
 
 
874
  except Exception as e:
875
  print("bot api send_media_group failed, fallback mtproto:", e)
876
+ send_album_mtproto(chat_id, specs, caption)
877
 
878
  # ========== 命令 ==========
879
  @bot.message_handler(commands=["start", "help"])
 
1068
  platform=platform,
1069
  limit=_img_caption_limit(data["images"]),
1070
  )
1071
+ lives = data.get("lives") or []
1072
+ has_live = any(lives) and LIVE_PHOTO_MODE != "off"
1073
+
1074
+ def _xhs_send_local():
1075
+ local_paths, local_lives = _download_gallery(
 
1076
  data["images"], tmp,
1077
  headers={
1078
  "User-Agent": XHS_HEADERS_UA["User-Agent"],
1079
  "Referer": "https://www.xiaohongshu.com/",
1080
  },
1081
+ lives=lives,
1082
  )
1083
  if not local_paths:
1084
  raise RuntimeError("小红书图片全部下载失败")
1085
+ _send_images(chat_id, local_paths, cap, local_files=True,
1086
+ lives=local_lives)
1087
+
1088
+ if has_live:
1089
+ # 实况的 mp4 直链 Telegram 服务器基本拉不动(防盗链),
1090
+ # 直接本地中转,省得发一半失败再整组重发出现重复
1091
+ _update_status(status, "🎞 正在下载实况图...")
1092
+ _xhs_send_local()
1093
+ else:
1094
+ try:
1095
+ _send_images(chat_id, data["images"], cap, lives=lives)
1096
+ except Exception as e:
1097
+ # URL 直发失败(CDN 防盗链)时,落地到本地再发
1098
+ print("xhs url images failed, download locally:", e)
1099
+ _xhs_send_local()
1100
  try:
1101
  if status:
1102
  bot.delete_message(status.chat.id, status.message_id)
 
1121
  if data and data.get("type") == "image" and data.get("images"):
1122
  # ★ 抖音图片 CDN 需要先下载到本地再发送
1123
  _update_status(status, "🖼 正在下载图片...")
1124
+ local_paths, local_lives = _download_gallery(
1125
  data["images"], tmp,
1126
  headers={
1127
  "User-Agent": DOUYIN_HEADERS["User-Agent"],
1128
  "Referer": "https://www.douyin.com/",
1129
  },
1130
+ lives=data.get("lives") or [],
1131
  )
1132
  if local_paths:
1133
  _update_status(status, "📤 正在发送图集...")
 
1142
  sender, url, platform=platform,
1143
  limit=_img_caption_limit(local_paths),
1144
  )
1145
+ _send_images(chat_id, local_paths, cap, local_files=True,
1146
+ lives=local_lives)
1147
  else:
1148
  raise RuntimeError("抖音图片全部下载失败")
1149
  try:
douyin_extract.py CHANGED
@@ -208,17 +208,37 @@ def _pick_image_url(url_list) -> str:
208
  return cands[0]
209
 
210
 
211
- def _get_images(aweme: dict) -> list:
212
- """抖音图文取图:aweme['images'] 是标准字段,image_post_info 为备用"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  if not isinstance(aweme, dict):
214
- return []
215
 
216
  img_list = aweme.get("images") or []
217
  if not img_list:
218
  ipi = aweme.get("image_post_info") or {}
219
  img_list = ipi.get("images") or []
220
 
221
- images = []
222
  for img in img_list:
223
  if not isinstance(img, dict):
224
  continue
@@ -242,9 +262,11 @@ def _get_images(aweme: dict) -> list:
242
 
243
  if picked and picked not in images:
244
  images.append(picked)
 
245
 
246
- print(f"[douyin] images: {len(images)} / raw {len(img_list)}")
247
- return images
 
248
 
249
 
250
  def _clean_music(mus: dict) -> str:
@@ -312,11 +334,13 @@ def extract_douyin(url: str, cookie_header: str = ""):
312
  music = _clean_music(aweme.get("music") or {})
313
 
314
  # ---------- 图文 ----------
315
- images = _get_images(aweme)
316
  if images:
317
  return {
318
  "type": "image",
319
  "images": images,
 
 
320
  "title": title,
321
  "tags": tags,
322
  "author": author,
 
208
  return cands[0]
209
 
210
 
211
+ def _pick_live_url(img: dict) -> str:
212
+ """
213
+ 抖音实况图(live 图):图文里每一张图都可能自带一个 video 对象,
214
+ 里面是这张图对应的几秒 mp4。没有就是普通静态图。
215
+ """
216
+ if not isinstance(img, dict):
217
+ return ""
218
+ for key in ("video", "clip_video", "live_photo", "livePhoto"):
219
+ v = img.get(key)
220
+ if isinstance(v, dict):
221
+ u = _get_best_video_url(v)
222
+ if u:
223
+ return u
224
+ return ""
225
+
226
+
227
+ def _get_images(aweme: dict):
228
+ """
229
+ 抖音图文取图:aweme['images'] 是标准字段,image_post_info 为备用。
230
+ 返回 (图片直链列表, live 视频直链列表),两个列表下标一一对应,
231
+ 非实况图对应的位置是空字符串。
232
+ """
233
  if not isinstance(aweme, dict):
234
+ return [], []
235
 
236
  img_list = aweme.get("images") or []
237
  if not img_list:
238
  ipi = aweme.get("image_post_info") or {}
239
  img_list = ipi.get("images") or []
240
 
241
+ images, lives = [], []
242
  for img in img_list:
243
  if not isinstance(img, dict):
244
  continue
 
262
 
263
  if picked and picked not in images:
264
  images.append(picked)
265
+ lives.append(_pick_live_url(img))
266
 
267
+ print(f"[douyin] images: {len(images)} / raw {len(img_list)}, "
268
+ f"live: {sum(1 for x in lives if x)}")
269
+ return images, lives
270
 
271
 
272
  def _clean_music(mus: dict) -> str:
 
334
  music = _clean_music(aweme.get("music") or {})
335
 
336
  # ---------- 图文 ----------
337
+ images, lives = _get_images(aweme)
338
  if images:
339
  return {
340
  "type": "image",
341
  "images": images,
342
+ "lives": lives,
343
+ "live_count": sum(1 for x in lives if x),
344
  "title": title,
345
  "tags": tags,
346
  "author": author,
static/index.html CHANGED
@@ -46,6 +46,10 @@ button.ghost{background:var(--card2);border:1px solid var(--line);color:var(--fg
46
  padding:1px 7px;font-size:11px}
47
  .cell .dl{position:absolute;right:6px;bottom:6px;background:rgba(0,0,0,.65);border-radius:8px;
48
  padding:4px 9px;font-size:12px;color:#fff;text-decoration:none}
 
 
 
 
49
  video{width:100%;border-radius:12px;background:#000;max-height:60vh}
50
  .acts{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
51
  .sp{height:14px}
@@ -132,15 +136,29 @@ function render(d){
132
  if(d.width && d.height) meta.push(`<span class="pill">${d.width}×${d.height}</span>`);
133
  if(d.duration) meta.push(`<span class="pill">${fmtDur(d.duration)}</span>`);
134
  if(d.music) meta.push(`<span class="pill">🎵 ${esc(d.music)}</span>`);
 
135
 
136
  let media = "";
137
  if(d.type === "image"){
138
- media = '<div class="grid">' + d.images_proxy.map((u,i)=>`
139
- <div class="cell">
 
 
 
 
 
 
 
 
 
 
 
 
140
  <img src="${u}" loading="lazy">
141
  <div class="n">${i+1}</div>
142
- <a class="dl" href="${u}&name=${encodeURIComponent((d.title||'img').slice(0,20)+'_'+(i+1))}" download>下载</a>
143
- </div>`).join("") + "</div>";
 
144
  } else {
145
  const src = d.video_proxy || d.video_url || "";
146
  media = `<video controls preload="metadata" ${d.cover_proxy?`poster="${d.cover_proxy}"`:""} src="${src}"></video>`;
@@ -150,6 +168,8 @@ function render(d){
150
  ? `<button class="ghost" onclick="sendTG()">发送到 Telegram</button>` : "";
151
  const dlAll = d.type === "image"
152
  ? `<button class="ghost" onclick="dlAll()">下载全部 ${d.count} 张</button>` : "";
 
 
153
  const dlVideo = d.type === "video"
154
  ? `<a class="ghost" style="display:inline-block;text-decoration:none;padding:8px 14px;border-radius:10px;background:var(--card2);border:1px solid var(--line);color:var(--fg);font-size:13px"
155
  href="${(d.video_proxy||d.video_url)}&name=${encodeURIComponent((d.title||'video').slice(0,20))}" download>下载视频</a>` : "";
@@ -161,7 +181,7 @@ function render(d){
161
  ${d.author ? `<div class="author">👤 ${d.author_url?`<a href="${d.author_url}" target="_blank">${esc(d.author)}</a>`:esc(d.author)}</div>` : ""}
162
  ${media}
163
  <div class="acts">
164
- ${dlAll}${dlVideo}
165
  <button class="ghost" onclick="copyText()">复制文案</button>
166
  <button class="ghost" onclick="copyLinks()">复制直链</button>
167
  ${tgBtn}
@@ -170,6 +190,31 @@ function render(d){
170
  box.scrollIntoView({behavior:"smooth", block:"nearest"});
171
  }
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  async function dlAll(){
174
  const d = LAST; if(!d) return;
175
  for(let i=0;i<d.images_proxy.length;i++){
@@ -190,7 +235,8 @@ function copyText(){
190
 
191
  function copyLinks(){
192
  const d = LAST; if(!d) return;
193
- const list = d.type === "image" ? d.images : [d.video_url];
 
194
  navigator.clipboard.writeText(list.join("\n")).then(()=>msg("原始直链已复制","info"));
195
  }
196
 
 
46
  padding:1px 7px;font-size:11px}
47
  .cell .dl{position:absolute;right:6px;bottom:6px;background:rgba(0,0,0,.65);border-radius:8px;
48
  padding:4px 9px;font-size:12px;color:#fff;text-decoration:none}
49
+ .cell video{width:100%;height:100%;object-fit:cover;display:block;border-radius:0;max-height:none}
50
+ .cell .lv{position:absolute;right:6px;top:6px;background:rgba(255,255,255,.92);color:#111;
51
+ border-radius:6px;padding:1px 6px;font-size:10px;font-weight:700;letter-spacing:.5px}
52
+ .cell.live{cursor:pointer}
53
  video{width:100%;border-radius:12px;background:#000;max-height:60vh}
54
  .acts{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
55
  .sp{height:14px}
 
136
  if(d.width && d.height) meta.push(`<span class="pill">${d.width}×${d.height}</span>`);
137
  if(d.duration) meta.push(`<span class="pill">${fmtDur(d.duration)}</span>`);
138
  if(d.music) meta.push(`<span class="pill">🎵 ${esc(d.music)}</span>`);
139
+ if(d.live_count) meta.push(`<span class="pill">🎞 ${d.live_count} 个实况</span>`);
140
 
141
  let media = "";
142
  if(d.type === "image"){
143
+ const lv = d.lives_proxy || [];
144
+ media = '<div class="grid">' + d.images_proxy.map((u,i)=>{
145
+ const v = lv[i] || "";
146
+ const name = encodeURIComponent((d.title||'img').slice(0,20)+'_'+(i+1));
147
+ if(v){
148
+ // 实况图:鼠标移上去 / 点一下就播放,下载给的是 mp4
149
+ return `<div class="cell live" onmouseenter="playLive(this)" onmouseleave="stopLive(this)" onclick="toggleLive(this)">
150
+ <video src="${v}" poster="${u}" muted loop playsinline preload="none"></video>
151
+ <div class="n">${i+1}</div>
152
+ <div class="lv">实况</div>
153
+ <a class="dl" href="${v}&name=${name}" download onclick="event.stopPropagation()">下载</a>
154
+ </div>`;
155
+ }
156
+ return `<div class="cell">
157
  <img src="${u}" loading="lazy">
158
  <div class="n">${i+1}</div>
159
+ <a class="dl" href="${u}&name=${name}" download>下载</a>
160
+ </div>`;
161
+ }).join("") + "</div>";
162
  } else {
163
  const src = d.video_proxy || d.video_url || "";
164
  media = `<video controls preload="metadata" ${d.cover_proxy?`poster="${d.cover_proxy}"`:""} src="${src}"></video>`;
 
168
  ? `<button class="ghost" onclick="sendTG()">发送到 Telegram</button>` : "";
169
  const dlAll = d.type === "image"
170
  ? `<button class="ghost" onclick="dlAll()">下载全部 ${d.count} 张</button>` : "";
171
+ const dlLive = (d.type === "image" && d.live_count)
172
+ ? `<button class="ghost" onclick="dlLive()">下载 ${d.live_count} 个实况</button>` : "";
173
  const dlVideo = d.type === "video"
174
  ? `<a class="ghost" style="display:inline-block;text-decoration:none;padding:8px 14px;border-radius:10px;background:var(--card2);border:1px solid var(--line);color:var(--fg);font-size:13px"
175
  href="${(d.video_proxy||d.video_url)}&name=${encodeURIComponent((d.title||'video').slice(0,20))}" download>下载视频</a>` : "";
 
181
  ${d.author ? `<div class="author">👤 ${d.author_url?`<a href="${d.author_url}" target="_blank">${esc(d.author)}</a>`:esc(d.author)}</div>` : ""}
182
  ${media}
183
  <div class="acts">
184
+ ${dlAll}${dlLive}${dlVideo}
185
  <button class="ghost" onclick="copyText()">复制文案</button>
186
  <button class="ghost" onclick="copyLinks()">复制直链</button>
187
  ${tgBtn}
 
190
  box.scrollIntoView({behavior:"smooth", block:"nearest"});
191
  }
192
 
193
+ function playLive(el){ const v = el.querySelector("video"); if(v) v.play().catch(()=>{}); }
194
+ function stopLive(el){ const v = el.querySelector("video"); if(v){ v.pause(); v.currentTime = 0; } }
195
+ function toggleLive(el){
196
+ const v = el.querySelector("video"); if(!v) return;
197
+ v.paused ? v.play().catch(()=>{}) : v.pause();
198
+ }
199
+
200
+ async function dlSeq(urls, base){
201
+ for(let i=0;i<urls.length;i++){
202
+ if(!urls[i]) continue;
203
+ const a = document.createElement("a");
204
+ a.href = urls[i] + "&name=" + encodeURIComponent(base+"_"+(i+1));
205
+ a.download = "";
206
+ document.body.appendChild(a); a.click(); a.remove();
207
+ await new Promise(r=>setTimeout(r,350));
208
+ }
209
+ }
210
+
211
+ async function dlLive(){
212
+ const d = LAST; if(!d) return;
213
+ const list = (d.lives_proxy||[]).filter(Boolean);
214
+ await dlSeq(list, (d.title||"live").slice(0,20));
215
+ msg("已触发 " + list.length + " 个实况下载", "info");
216
+ }
217
+
218
  async function dlAll(){
219
  const d = LAST; if(!d) return;
220
  for(let i=0;i<d.images_proxy.length;i++){
 
235
 
236
  function copyLinks(){
237
  const d = LAST; if(!d) return;
238
+ let list = d.type === "image" ? d.images.slice() : [d.video_url];
239
+ if(d.type === "image") list = list.concat((d.lives||[]).filter(Boolean));
240
  navigator.clipboard.writeText(list.join("\n")).then(()=>msg("原始直链已复制","info"));
241
  }
242
 
xhs_extract.py CHANGED
@@ -91,6 +91,37 @@ def _grab_json_object(html: str, anchor: str):
91
  return None
92
 
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  def _normalize_xhs_url(url: str) -> str:
95
  """xhslink.cn / xhs.cn 等 .cn 短链统一走 https,笔记页统一 www.xiaohongshu.com"""
96
  url = url.strip()
@@ -190,22 +221,21 @@ def extract_xhs(url: str, cookie_header: str = ""):
190
 
191
  # 图片
192
  image_list = note.get("imageList") or note.get("image_list") or []
193
- images = []
194
  for img in image_list:
195
  if isinstance(img, dict):
196
- u = (
197
  img.get("urlDefault")
198
  or img.get("url_default")
199
  or img.get("url")
200
  or img.get("original")
201
  or ""
202
  )
203
- if u:
204
- if u.startswith("//"):
205
- u = "https:" + u
206
- u = u.replace("http://", "https://")
207
- if u not in images:
208
- images.append(u)
209
 
210
  # 视频
211
  video_url = ""
@@ -253,6 +283,8 @@ def extract_xhs(url: str, cookie_header: str = ""):
253
  return {
254
  "type": "image",
255
  "images": images,
 
 
256
  "title": title,
257
  "tags": tags,
258
  "author": author,
 
91
  return None
92
 
93
 
94
+ def _norm_url(u) -> str:
95
+ if isinstance(u, list):
96
+ u = u[0] if u else ""
97
+ if not isinstance(u, str) or not u:
98
+ return ""
99
+ if u.startswith("//"):
100
+ u = "https:" + u
101
+ return u.replace("http://", "https://")
102
+
103
+
104
+ def _pick_live_url(img: dict) -> str:
105
+ """
106
+ 小红书实况图(live 图):imageList[i].stream 里带这张图对应的短视频。
107
+ 优先 h264 —— Telegram 客户端对 h265/av1 支持很差。
108
+ """
109
+ if not isinstance(img, dict):
110
+ return ""
111
+ stream = img.get("stream") or img.get("livePhoto") or {}
112
+ if not isinstance(stream, dict):
113
+ return ""
114
+ for quality in ("h264", "h265", "av1", "h266"):
115
+ for s in (stream.get(quality) or []):
116
+ if not isinstance(s, dict):
117
+ continue
118
+ u = _norm_url(s.get("masterUrl")) or _norm_url(s.get("backupUrls")) \
119
+ or _norm_url(s.get("backupUrl"))
120
+ if u:
121
+ return u
122
+ return ""
123
+
124
+
125
  def _normalize_xhs_url(url: str) -> str:
126
  """xhslink.cn / xhs.cn 等 .cn 短链统一走 https,笔记页统一 www.xiaohongshu.com"""
127
  url = url.strip()
 
221
 
222
  # 图片
223
  image_list = note.get("imageList") or note.get("image_list") or []
224
+ images, lives = [], []
225
  for img in image_list:
226
  if isinstance(img, dict):
227
+ u = _norm_url(
228
  img.get("urlDefault")
229
  or img.get("url_default")
230
  or img.get("url")
231
  or img.get("original")
232
  or ""
233
  )
234
+ if u and u not in images:
235
+ images.append(u)
236
+ lives.append(_pick_live_url(img))
237
+ if any(lives):
238
+ print(f"[xhs] images: {len(images)}, live: {sum(1 for x in lives if x)}")
 
239
 
240
  # 视频
241
  video_url = ""
 
283
  return {
284
  "type": "image",
285
  "images": images,
286
+ "lives": lives,
287
+ "live_count": sum(1 for x in lives if x),
288
  "title": title,
289
  "tags": tags,
290
  "author": author,