支持实况图(live图):抖音/小红书 live 提取、相册内图视频混发、Web 端可播放可下载
Browse files- app.py +10 -0
- big_send.py +16 -21
- bot.py +155 -60
- douyin_extract.py +31 -7
- static/index.html +52 -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
|
| 59 |
-
"""
|
| 60 |
-
from pyrogram.types import InputMediaPhoto
|
| 61 |
async with _get_client() as app:
|
| 62 |
media = []
|
| 63 |
-
for i,
|
|
|
|
| 64 |
if i == 0 and caption:
|
| 65 |
-
|
|
|
|
|
|
|
| 66 |
else:
|
| 67 |
-
media.append(InputMediaPhoto(
|
| 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 |
-
|
| 93 |
|
| 94 |
|
| 95 |
def send_local_photos_mtproto(chat_id, paths, caption=""):
|
| 96 |
"""发送本地图片文件(通过 mtproto)"""
|
| 97 |
-
|
|
|
|
| 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,
|
|
|
|
| 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
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
for i, url in enumerate(urls[:MAX_IMAGES]):
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 716 |
"""
|
| 717 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 718 |
(抖音图文经常有 13~35 张,原来直接被截断到 10 张)。
|
| 719 |
|
|
|
|
|
|
|
| 720 |
文案位置:
|
| 721 |
-
· 只有一组(≤10
|
| 722 |
· 多组 —— 图先全部发完,文案 / 作者 / 原链接作为最后一条消息单独发,
|
| 723 |
免得刷了几十张图之后还要往上翻才能看到文案和原链接。
|
| 724 |
"""
|
| 725 |
if not images:
|
| 726 |
return
|
| 727 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 754 |
if local_files:
|
| 755 |
# ---- 本地文件:先尝试 Bot API,失败则走 mtproto ----
|
| 756 |
files = []
|
| 757 |
try:
|
| 758 |
media = []
|
| 759 |
-
for i, fp in enumerate(
|
| 760 |
f = open(fp, "rb")
|
| 761 |
files.append(f)
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 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
|
| 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 |
-
|
| 779 |
except Exception as e2:
|
| 780 |
-
print("mtproto send local
|
| 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 = [
|
| 791 |
-
|
| 792 |
-
|
| 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 |
-
|
| 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 |
-
|
| 993 |
-
|
| 994 |
-
|
| 995 |
-
|
| 996 |
-
|
| 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 =
|
| 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
|
| 212 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
<img src="${u}" loading="lazy">
|
| 141 |
<div class="n">${i+1}</div>
|
| 142 |
-
<a class="dl" href="${u}&name=${
|
| 143 |
-
</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 |
-
|
|
|
|
| 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 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 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,
|