From 200283b21bd94d9c3b658c2cd9b1053a9d221705 Mon Sep 17 00:00:00 2001 From: feie9456 Date: Tue, 21 Oct 2025 16:33:16 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=89=8D=E7=AB=AF=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E8=BF=94=E5=9B=9E=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=A7=86=E9=A2=91=E5=AE=9E=E6=97=B6=E8=83=8C?= =?UTF-8?q?=E6=99=AF=EF=BC=8C=E9=9F=B3=E9=87=8F=E5=80=8D=E9=80=9F=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/tasks.json | 9 + app/api/aweme/around/route.ts | 77 +++++++ app/aweme/[awemeId]/Client.tsx | 372 ++++++++++++++++++++++++++++-- app/aweme/[awemeId]/emojis.ts | 3 + app/aweme/[awemeId]/page.tsx | 28 ++- app/components/BackButton.tsx | 24 +- app/components/FeedMasonry.tsx | 2 +- app/fetcher/browser.ts | 71 ++++++ app/fetcher/index.ts | 66 +++++- app/fetcher/network.ts | 2 +- app/fetcher/route.ts | 42 +++- app/tasks/page.tsx | 105 ++++++++- public/emojis/18禁.webp | Bin 0 -> 5086 bytes public/emojis/666.webp | Bin 0 -> 3870 bytes public/emojis/OK.webp | Bin 0 -> 5078 bytes public/emojis/V5.webp | Bin 0 -> 4678 bytes public/emojis/candy.webp | Bin 0 -> 1986 bytes public/emojis/kisskiss.webp | Bin 0 -> 7192 bytes public/emojis/okk.webp | Bin 0 -> 2884 bytes public/emojis/一头乱麻.webp | Bin 0 -> 9202 bytes public/emojis/一起加油.webp | Bin 0 -> 7832 bytes public/emojis/不你不想.webp | Bin 0 -> 8306 bytes public/emojis/不失礼貌的微笑.webp | Bin 0 -> 7426 bytes public/emojis/不是吧.webp | Bin 0 -> 7438 bytes public/emojis/不看.webp | Bin 0 -> 8370 bytes public/emojis/举手.webp | Bin 0 -> 7532 bytes public/emojis/九转大肠.webp | Bin 0 -> 7052 bytes public/emojis/二哈.webp | Bin 0 -> 8280 bytes public/emojis/互粉.webp | Bin 0 -> 7916 bytes public/emojis/你不大行.webp | Bin 0 -> 7522 bytes public/emojis/做鬼脸.webp | Bin 0 -> 7464 bytes public/emojis/偷笑.webp | Bin 0 -> 6366 bytes public/emojis/元宝.webp | Bin 0 -> 2044 bytes public/emojis/再见.webp | Bin 0 -> 6586 bytes public/emojis/冷漠.webp | Bin 0 -> 6284 bytes public/emojis/凋谢.webp | Bin 0 -> 5320 bytes public/emojis/减一.webp | Bin 0 -> 1982 bytes public/emojis/击掌.webp | Bin 0 -> 6838 bytes public/emojis/加一.webp | Bin 0 -> 2188 bytes public/emojis/加功德.webp | Bin 0 -> 10006 bytes public/emojis/加鸡腿.webp | Bin 0 -> 5174 bytes public/emojis/勾引.webp | Bin 0 -> 5458 bytes public/emojis/发.webp | Bin 0 -> 4572 bytes public/emojis/发呆.webp | Bin 0 -> 6332 bytes public/emojis/发怒.webp | Bin 0 -> 7584 bytes public/emojis/可怜.webp | Bin 0 -> 7838 bytes public/emojis/右边.webp | Bin 0 -> 4288 bytes public/emojis/叹气.webp | Bin 0 -> 6592 bytes public/emojis/吃瓜群众.webp | Bin 0 -> 7840 bytes public/emojis/吐.webp | Bin 0 -> 9132 bytes public/emojis/吐彩虹.webp | Bin 0 -> 7212 bytes public/emojis/吐舌.webp | Bin 0 -> 8106 bytes public/emojis/吐舌小狗.webp | Bin 0 -> 8582 bytes public/emojis/吐血.webp | Bin 0 -> 6628 bytes public/emojis/听歌.webp | Bin 0 -> 5932 bytes public/emojis/呆无辜.webp | Bin 0 -> 7218 bytes public/emojis/呲牙.webp | Bin 0 -> 5792 bytes public/emojis/咒骂.webp | Bin 0 -> 7490 bytes public/emojis/咖啡.webp | Bin 0 -> 5022 bytes public/emojis/哈欠.webp | Bin 0 -> 6780 bytes public/emojis/哭哭.webp | Bin 0 -> 5210 bytes public/emojis/啤酒.webp | Bin 0 -> 5912 bytes public/emojis/嘘.webp | Bin 0 -> 7214 bytes public/emojis/嘴唇.webp | Bin 0 -> 5398 bytes public/emojis/嘿哈.webp | Bin 0 -> 7556 bytes public/emojis/噢买尬.webp | Bin 0 -> 6952 bytes public/emojis/囧.webp | Bin 0 -> 6252 bytes public/emojis/困.webp | Bin 0 -> 5740 bytes public/emojis/圣诞帽.webp | Bin 0 -> 3100 bytes public/emojis/圣诞树.webp | Bin 0 -> 2996 bytes public/emojis/坏笑.webp | Bin 0 -> 5856 bytes public/emojis/垃圾.webp | Bin 0 -> 3840 bytes public/emojis/大哭.webp | Bin 0 -> 7824 bytes public/emojis/大笑.webp | Bin 0 -> 5858 bytes public/emojis/大金牙.webp | Bin 0 -> 5938 bytes public/emojis/太阳.webp | Bin 0 -> 5976 bytes public/emojis/奋斗.webp | Bin 0 -> 7506 bytes public/emojis/奸笑.webp | Bin 0 -> 7376 bytes public/emojis/好开心.webp | Bin 0 -> 6896 bytes public/emojis/如花.webp | Bin 0 -> 6396 bytes public/emojis/委屈.webp | Bin 0 -> 7236 bytes public/emojis/宕机.webp | Bin 0 -> 7256 bytes public/emojis/害羞.webp | Bin 0 -> 5896 bytes public/emojis/小鼓掌.webp | Bin 0 -> 7132 bytes public/emojis/尬笑.webp | Bin 0 -> 7444 bytes public/emojis/尴尬流汗.webp | Bin 0 -> 7266 bytes public/emojis/屎.webp | Bin 0 -> 7776 bytes public/emojis/展开说说.webp | Bin 0 -> 7120 bytes public/emojis/左上.webp | Bin 0 -> 4678 bytes public/emojis/左边.webp | Bin 0 -> 4356 bytes public/emojis/巧克力.webp | Bin 0 -> 2712 bytes public/emojis/干饭人.webp | Bin 0 -> 9474 bytes public/emojis/平安果.webp | Bin 0 -> 2598 bytes public/emojis/庆祝.webp | Bin 0 -> 9044 bytes public/emojis/弱.webp | Bin 0 -> 4412 bytes public/emojis/强.webp | Bin 0 -> 7732 bytes public/emojis/强壮.webp | Bin 0 -> 4748 bytes public/emojis/得意.webp | Bin 0 -> 6552 bytes public/emojis/微笑.webp | Bin 0 -> 6864 bytes public/emojis/心碎.webp | Bin 0 -> 6896 bytes public/emojis/快哭了.webp | Bin 0 -> 6842 bytes public/emojis/思考.webp | Bin 0 -> 7502 bytes public/emojis/恐惧.webp | Bin 0 -> 8358 bytes public/emojis/悠闲.webp | Bin 0 -> 6918 bytes public/emojis/惊喜.webp | Bin 0 -> 7368 bytes public/emojis/惊恐.webp | Bin 0 -> 6748 bytes public/emojis/愉快.webp | Bin 0 -> 5740 bytes public/emojis/感谢.webp | Bin 0 -> 5346 bytes public/emojis/愤怒.webp | Bin 0 -> 4954 bytes public/emojis/憨笑.webp | Bin 0 -> 6464 bytes public/emojis/懵.webp | Bin 0 -> 5468 bytes public/emojis/我想静静.webp | Bin 0 -> 7714 bytes public/emojis/戒指.webp | Bin 0 -> 2694 bytes public/emojis/戳手手.webp | Bin 0 -> 7892 bytes public/emojis/扎心.webp | Bin 0 -> 4606 bytes public/emojis/打call.webp | Bin 0 -> 5842 bytes public/emojis/打脸.webp | Bin 0 -> 8664 bytes public/emojis/抓狂.webp | Bin 0 -> 6746 bytes public/emojis/抠鼻.webp | Bin 0 -> 6336 bytes public/emojis/抱抱你.webp | Bin 0 -> 8084 bytes public/emojis/抱拳.webp | Bin 0 -> 5400 bytes public/emojis/抱紧自己.webp | Bin 0 -> 7594 bytes public/emojis/拜拜.webp | Bin 0 -> 6316 bytes public/emojis/拳头.webp | Bin 0 -> 5446 bytes public/emojis/捂脸.webp | Bin 0 -> 7690 bytes public/emojis/握手.webp | Bin 0 -> 6740 bytes public/emojis/握爪.webp | Bin 0 -> 4952 bytes public/emojis/摊手.webp | Bin 0 -> 7438 bytes public/emojis/摸头.webp | Bin 0 -> 7582 bytes public/emojis/撇嘴.webp | Bin 0 -> 6310 bytes public/emojis/撒花.webp | Bin 0 -> 6452 bytes public/emojis/擦汗.webp | Bin 0 -> 7662 bytes public/emojis/敢怒不敢言.webp | Bin 0 -> 7270 bytes public/emojis/敲打.webp | Bin 0 -> 7552 bytes public/emojis/斜眼.webp | Bin 0 -> 5376 bytes public/emojis/无语流汗.webp | Bin 0 -> 5228 bytes public/emojis/星星眼.webp | Bin 0 -> 7752 bytes public/emojis/晕.webp | Bin 0 -> 6904 bytes public/emojis/暗中观察.webp | Bin 0 -> 6752 bytes public/emojis/月亮.webp | Bin 0 -> 6240 bytes public/emojis/机智.webp | Bin 0 -> 7752 bytes public/emojis/杀马特.webp | Bin 0 -> 6604 bytes public/emojis/来看我.webp | Bin 0 -> 8188 bytes public/emojis/柴犬.webp | Bin 0 -> 7648 bytes public/emojis/栓Q.webp | Bin 0 -> 5232 bytes public/emojis/棒棒糖.webp | Bin 0 -> 3250 bytes public/emojis/比心.webp | Bin 0 -> 5900 bytes public/emojis/气球.webp | Bin 0 -> 2212 bytes public/emojis/求抱抱.webp | Bin 0 -> 8496 bytes public/emojis/求机位-黄脸.webp | Bin 0 -> 9362 bytes public/emojis/求机位3.webp | Bin 0 -> 7130 bytes public/emojis/求求了.webp | Bin 0 -> 8240 bytes public/emojis/泣不成声.webp | Bin 0 -> 8618 bytes public/emojis/泪奔.webp | Bin 0 -> 6714 bytes public/emojis/流泪.webp | Bin 0 -> 6610 bytes public/emojis/灯笼.webp | Bin 0 -> 2398 bytes public/emojis/灵机一动.webp | Bin 0 -> 7938 bytes public/emojis/炸弹.webp | Bin 0 -> 6812 bytes public/emojis/点火.webp | Bin 0 -> 5398 bytes public/emojis/点赞.webp | Bin 0 -> 4610 bytes public/emojis/烟花.webp | Bin 0 -> 9054 bytes public/emojis/热化了.webp | Bin 0 -> 7600 bytes public/emojis/爱心.webp | Bin 0 -> 4368 bytes public/emojis/爱心手.webp | Bin 0 -> 5372 bytes public/emojis/猪头.webp | Bin 0 -> 7152 bytes public/emojis/玫瑰.webp | Bin 0 -> 4422 bytes public/emojis/疑问.webp | Bin 0 -> 7734 bytes public/emojis/白眼.webp | Bin 0 -> 7364 bytes public/emojis/皱眉.webp | Bin 0 -> 5524 bytes public/emojis/看.webp | Bin 0 -> 6938 bytes public/emojis/眼含热泪.webp | Bin 0 -> 7262 bytes public/emojis/石化.webp | Bin 0 -> 6550 bytes public/emojis/碰拳.webp | Bin 0 -> 5674 bytes public/emojis/礼物.webp | Bin 0 -> 8240 bytes public/emojis/福.webp | Bin 0 -> 3348 bytes public/emojis/笑哭.webp | Bin 0 -> 7248 bytes public/emojis/粽子.webp | Bin 0 -> 7958 bytes public/emojis/精选.webp | Bin 0 -> 9206 bytes public/emojis/糖葫芦.webp | Bin 0 -> 4186 bytes public/emojis/紫薇别走.webp | Bin 0 -> 7840 bytes public/emojis/红包.webp | Bin 0 -> 3854 bytes public/emojis/红脸.webp | Bin 0 -> 5726 bytes public/emojis/纸飞机.webp | Bin 0 -> 2726 bytes public/emojis/给力.webp | Bin 0 -> 2900 bytes public/emojis/给跪了.webp | Bin 0 -> 5864 bytes public/emojis/绝.webp | Bin 0 -> 3056 bytes public/emojis/绝望的凝视.webp | Bin 0 -> 6948 bytes public/emojis/续火花吧.webp | Bin 0 -> 5868 bytes public/emojis/绿帽子.webp | Bin 0 -> 7326 bytes public/emojis/翻白眼.webp | Bin 0 -> 5858 bytes public/emojis/耶.webp | Bin 0 -> 7982 bytes public/emojis/胜利.webp | Bin 0 -> 5192 bytes public/emojis/胡瓜.webp | Bin 0 -> 6090 bytes public/emojis/舔屏.webp | Bin 0 -> 7440 bytes public/emojis/色.webp | Bin 0 -> 6718 bytes public/emojis/苦涩.webp | Bin 0 -> 7182 bytes public/emojis/菜狗.webp | Bin 0 -> 12106 bytes public/emojis/蕉绿.webp | Bin 0 -> 5978 bytes public/emojis/蛋糕.webp | Bin 0 -> 5290 bytes public/emojis/蜜蜂狗.webp | Bin 0 -> 7224 bytes public/emojis/衰.webp | Bin 0 -> 5652 bytes public/emojis/裂开.webp | Bin 0 -> 9064 bytes public/emojis/西瓜.webp | Bin 0 -> 4414 bytes public/emojis/调皮.webp | Bin 0 -> 6720 bytes public/emojis/贴贴.webp | Bin 0 -> 8898 bytes public/emojis/赞.webp | Bin 0 -> 4234 bytes public/emojis/躺平.webp | Bin 0 -> 5046 bytes public/emojis/送心.webp | Bin 0 -> 8066 bytes public/emojis/送花.webp | Bin 0 -> 7536 bytes public/emojis/逞强落泪.webp | Bin 0 -> 6026 bytes public/emojis/鄙视.webp | Bin 0 -> 6644 bytes public/emojis/酷拽.webp | Bin 0 -> 6156 bytes public/emojis/钱.webp | Bin 0 -> 6330 bytes public/emojis/锦鲤.webp | Bin 0 -> 2122 bytes public/emojis/闭嘴.webp | Bin 0 -> 6608 bytes public/emojis/阴险.webp | Bin 0 -> 6664 bytes public/emojis/难过.webp | Bin 0 -> 6010 bytes public/emojis/雪花.webp | Bin 0 -> 8654 bytes public/emojis/震惊.webp | Bin 0 -> 6642 bytes public/emojis/鞠躬.webp | Bin 0 -> 6446 bytes public/emojis/鞭炮.webp | Bin 0 -> 3924 bytes public/emojis/飞吻.webp | Bin 0 -> 7124 bytes public/emojis/黄脸干杯.webp | Bin 0 -> 7946 bytes public/emojis/黄脸祈祷.webp | Bin 0 -> 7846 bytes public/emojis/黑脸.webp | Bin 0 -> 6282 bytes public/emojis/鼓掌.webp | Bin 0 -> 6522 bytes public/file.svg | 1 - public/globe.svg | 1 - public/next.svg | 1 - public/vercel.svg | 1 - public/window.svg | 1 - 231 files changed, 744 insertions(+), 62 deletions(-) create mode 100644 app/api/aweme/around/route.ts create mode 100644 app/aweme/[awemeId]/emojis.ts create mode 100644 app/fetcher/browser.ts create mode 100644 public/emojis/18禁.webp create mode 100644 public/emojis/666.webp create mode 100644 public/emojis/OK.webp create mode 100644 public/emojis/V5.webp create mode 100644 public/emojis/candy.webp create mode 100644 public/emojis/kisskiss.webp create mode 100644 public/emojis/okk.webp create mode 100644 public/emojis/一头乱麻.webp create mode 100644 public/emojis/一起加油.webp create mode 100644 public/emojis/不你不想.webp create mode 100644 public/emojis/不失礼貌的微笑.webp create mode 100644 public/emojis/不是吧.webp create mode 100644 public/emojis/不看.webp create mode 100644 public/emojis/举手.webp create mode 100644 public/emojis/九转大肠.webp create mode 100644 public/emojis/二哈.webp create mode 100644 public/emojis/互粉.webp create mode 100644 public/emojis/你不大行.webp create mode 100644 public/emojis/做鬼脸.webp create mode 100644 public/emojis/偷笑.webp create mode 100644 public/emojis/元宝.webp create mode 100644 public/emojis/再见.webp create mode 100644 public/emojis/冷漠.webp create mode 100644 public/emojis/凋谢.webp create mode 100644 public/emojis/减一.webp create mode 100644 public/emojis/击掌.webp create mode 100644 public/emojis/加一.webp create mode 100644 public/emojis/加功德.webp create mode 100644 public/emojis/加鸡腿.webp create mode 100644 public/emojis/勾引.webp create mode 100644 public/emojis/发.webp create mode 100644 public/emojis/发呆.webp create mode 100644 public/emojis/发怒.webp create mode 100644 public/emojis/可怜.webp create mode 100644 public/emojis/右边.webp create mode 100644 public/emojis/叹气.webp create mode 100644 public/emojis/吃瓜群众.webp create mode 100644 public/emojis/吐.webp create mode 100644 public/emojis/吐彩虹.webp create mode 100644 public/emojis/吐舌.webp create mode 100644 public/emojis/吐舌小狗.webp create mode 100644 public/emojis/吐血.webp create mode 100644 public/emojis/听歌.webp create mode 100644 public/emojis/呆无辜.webp create mode 100644 public/emojis/呲牙.webp create mode 100644 public/emojis/咒骂.webp create mode 100644 public/emojis/咖啡.webp create mode 100644 public/emojis/哈欠.webp create mode 100644 public/emojis/哭哭.webp create mode 100644 public/emojis/啤酒.webp create mode 100644 public/emojis/嘘.webp create mode 100644 public/emojis/嘴唇.webp create mode 100644 public/emojis/嘿哈.webp create mode 100644 public/emojis/噢买尬.webp create mode 100644 public/emojis/囧.webp create mode 100644 public/emojis/困.webp create mode 100644 public/emojis/圣诞帽.webp create mode 100644 public/emojis/圣诞树.webp create mode 100644 public/emojis/坏笑.webp create mode 100644 public/emojis/垃圾.webp create mode 100644 public/emojis/大哭.webp create mode 100644 public/emojis/大笑.webp create mode 100644 public/emojis/大金牙.webp create mode 100644 public/emojis/太阳.webp create mode 100644 public/emojis/奋斗.webp create mode 100644 public/emojis/奸笑.webp create mode 100644 public/emojis/好开心.webp create mode 100644 public/emojis/如花.webp create mode 100644 public/emojis/委屈.webp create mode 100644 public/emojis/宕机.webp create mode 100644 public/emojis/害羞.webp create mode 100644 public/emojis/小鼓掌.webp create mode 100644 public/emojis/尬笑.webp create mode 100644 public/emojis/尴尬流汗.webp create mode 100644 public/emojis/屎.webp create mode 100644 public/emojis/展开说说.webp create mode 100644 public/emojis/左上.webp create mode 100644 public/emojis/左边.webp create mode 100644 public/emojis/巧克力.webp create mode 100644 public/emojis/干饭人.webp create mode 100644 public/emojis/平安果.webp create mode 100644 public/emojis/庆祝.webp create mode 100644 public/emojis/弱.webp create mode 100644 public/emojis/强.webp create mode 100644 public/emojis/强壮.webp create mode 100644 public/emojis/得意.webp create mode 100644 public/emojis/微笑.webp create mode 100644 public/emojis/心碎.webp create mode 100644 public/emojis/快哭了.webp create mode 100644 public/emojis/思考.webp create mode 100644 public/emojis/恐惧.webp create mode 100644 public/emojis/悠闲.webp create mode 100644 public/emojis/惊喜.webp create mode 100644 public/emojis/惊恐.webp create mode 100644 public/emojis/愉快.webp create mode 100644 public/emojis/感谢.webp create mode 100644 public/emojis/愤怒.webp create mode 100644 public/emojis/憨笑.webp create mode 100644 public/emojis/懵.webp create mode 100644 public/emojis/我想静静.webp create mode 100644 public/emojis/戒指.webp create mode 100644 public/emojis/戳手手.webp create mode 100644 public/emojis/扎心.webp create mode 100644 public/emojis/打call.webp create mode 100644 public/emojis/打脸.webp create mode 100644 public/emojis/抓狂.webp create mode 100644 public/emojis/抠鼻.webp create mode 100644 public/emojis/抱抱你.webp create mode 100644 public/emojis/抱拳.webp create mode 100644 public/emojis/抱紧自己.webp create mode 100644 public/emojis/拜拜.webp create mode 100644 public/emojis/拳头.webp create mode 100644 public/emojis/捂脸.webp create mode 100644 public/emojis/握手.webp create mode 100644 public/emojis/握爪.webp create mode 100644 public/emojis/摊手.webp create mode 100644 public/emojis/摸头.webp create mode 100644 public/emojis/撇嘴.webp create mode 100644 public/emojis/撒花.webp create mode 100644 public/emojis/擦汗.webp create mode 100644 public/emojis/敢怒不敢言.webp create mode 100644 public/emojis/敲打.webp create mode 100644 public/emojis/斜眼.webp create mode 100644 public/emojis/无语流汗.webp create mode 100644 public/emojis/星星眼.webp create mode 100644 public/emojis/晕.webp create mode 100644 public/emojis/暗中观察.webp create mode 100644 public/emojis/月亮.webp create mode 100644 public/emojis/机智.webp create mode 100644 public/emojis/杀马特.webp create mode 100644 public/emojis/来看我.webp create mode 100644 public/emojis/柴犬.webp create mode 100644 public/emojis/栓Q.webp create mode 100644 public/emojis/棒棒糖.webp create mode 100644 public/emojis/比心.webp create mode 100644 public/emojis/气球.webp create mode 100644 public/emojis/求抱抱.webp create mode 100644 public/emojis/求机位-黄脸.webp create mode 100644 public/emojis/求机位3.webp create mode 100644 public/emojis/求求了.webp create mode 100644 public/emojis/泣不成声.webp create mode 100644 public/emojis/泪奔.webp create mode 100644 public/emojis/流泪.webp create mode 100644 public/emojis/灯笼.webp create mode 100644 public/emojis/灵机一动.webp create mode 100644 public/emojis/炸弹.webp create mode 100644 public/emojis/点火.webp create mode 100644 public/emojis/点赞.webp create mode 100644 public/emojis/烟花.webp create mode 100644 public/emojis/热化了.webp create mode 100644 public/emojis/爱心.webp create mode 100644 public/emojis/爱心手.webp create mode 100644 public/emojis/猪头.webp create mode 100644 public/emojis/玫瑰.webp create mode 100644 public/emojis/疑问.webp create mode 100644 public/emojis/白眼.webp create mode 100644 public/emojis/皱眉.webp create mode 100644 public/emojis/看.webp create mode 100644 public/emojis/眼含热泪.webp create mode 100644 public/emojis/石化.webp create mode 100644 public/emojis/碰拳.webp create mode 100644 public/emojis/礼物.webp create mode 100644 public/emojis/福.webp create mode 100644 public/emojis/笑哭.webp create mode 100644 public/emojis/粽子.webp create mode 100644 public/emojis/精选.webp create mode 100644 public/emojis/糖葫芦.webp create mode 100644 public/emojis/紫薇别走.webp create mode 100644 public/emojis/红包.webp create mode 100644 public/emojis/红脸.webp create mode 100644 public/emojis/纸飞机.webp create mode 100644 public/emojis/给力.webp create mode 100644 public/emojis/给跪了.webp create mode 100644 public/emojis/绝.webp create mode 100644 public/emojis/绝望的凝视.webp create mode 100644 public/emojis/续火花吧.webp create mode 100644 public/emojis/绿帽子.webp create mode 100644 public/emojis/翻白眼.webp create mode 100644 public/emojis/耶.webp create mode 100644 public/emojis/胜利.webp create mode 100644 public/emojis/胡瓜.webp create mode 100644 public/emojis/舔屏.webp create mode 100644 public/emojis/色.webp create mode 100644 public/emojis/苦涩.webp create mode 100644 public/emojis/菜狗.webp create mode 100644 public/emojis/蕉绿.webp create mode 100644 public/emojis/蛋糕.webp create mode 100644 public/emojis/蜜蜂狗.webp create mode 100644 public/emojis/衰.webp create mode 100644 public/emojis/裂开.webp create mode 100644 public/emojis/西瓜.webp create mode 100644 public/emojis/调皮.webp create mode 100644 public/emojis/贴贴.webp create mode 100644 public/emojis/赞.webp create mode 100644 public/emojis/躺平.webp create mode 100644 public/emojis/送心.webp create mode 100644 public/emojis/送花.webp create mode 100644 public/emojis/逞强落泪.webp create mode 100644 public/emojis/鄙视.webp create mode 100644 public/emojis/酷拽.webp create mode 100644 public/emojis/钱.webp create mode 100644 public/emojis/锦鲤.webp create mode 100644 public/emojis/闭嘴.webp create mode 100644 public/emojis/阴险.webp create mode 100644 public/emojis/难过.webp create mode 100644 public/emojis/雪花.webp create mode 100644 public/emojis/震惊.webp create mode 100644 public/emojis/鞠躬.webp create mode 100644 public/emojis/鞭炮.webp create mode 100644 public/emojis/飞吻.webp create mode 100644 public/emojis/黄脸干杯.webp create mode 100644 public/emojis/黄脸祈祷.webp create mode 100644 public/emojis/黑脸.webp create mode 100644 public/emojis/鼓掌.webp delete mode 100644 public/file.svg delete mode 100644 public/globe.svg delete mode 100644 public/next.svg delete mode 100644 public/vercel.svg delete mode 100644 public/window.svg diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 9fbee17..9cdf630 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -13,6 +13,15 @@ "$tsc" ], "group": "build" + }, + { + "label": "tsc-check (one-off)", + "type": "shell", + "command": "node", + "args": [ + "-e", + "require('typescript').transpile('const x: number = 1;')" + ] } ] } \ No newline at end of file diff --git a/app/api/aweme/around/route.ts b/app/api/aweme/around/route.ts new file mode 100644 index 0000000..01f3266 --- /dev/null +++ b/app/api/aweme/around/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +// GET /api/aweme/around?awemeId=xxxx +// Response: { prev?: { type: 'video'|'image', aweme_id: string, created_at: string }, next?: { ... } } +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const awemeId = searchParams.get("awemeId"); + if (!awemeId) { + return NextResponse.json({ error: "missing awemeId" }, { status: 400 }); + } + + // Find current item timestamp from either table + const [video, post] = await Promise.all([ + prisma.video.findUnique({ where: { aweme_id: awemeId }, select: { aweme_id: true, created_at: true } }), + prisma.imagePost.findUnique({ where: { aweme_id: awemeId }, select: { aweme_id: true, created_at: true } }), + ]); + + const current = video ?? post; + if (!current) { + return NextResponse.json({ error: "aweme not found" }, { status: 404 }); + } + + const createdAt = current.created_at as unknown as Date; + + // Newer than current (prev in a desc-ordered feed): pick the nearest newer by created_at + const [newerVideo, newerPost] = await Promise.all([ + prisma.video.findFirst({ + where: { created_at: { gt: createdAt } }, + orderBy: { created_at: "asc" }, + select: { aweme_id: true, created_at: true }, + }), + prisma.imagePost.findFirst({ + where: { created_at: { gt: createdAt } }, + orderBy: { created_at: "asc" }, + select: { aweme_id: true, created_at: true }, + }), + ]); + + // Older than current (next in a desc-ordered feed): pick the nearest older by created_at + const [olderVideo, olderPost] = await Promise.all([ + prisma.video.findFirst({ + where: { created_at: { lt: createdAt } }, + orderBy: { created_at: "desc" }, + select: { aweme_id: true, created_at: true }, + }), + prisma.imagePost.findFirst({ + where: { created_at: { lt: createdAt } }, + orderBy: { created_at: "desc" }, + select: { aweme_id: true, created_at: true }, + }), + ]); + + const pickPrev = (() => { + const cands: { type: "video" | "image"; aweme_id: string; created_at: Date }[] = []; + if (newerVideo) cands.push({ type: "video", aweme_id: newerVideo.aweme_id, created_at: newerVideo.created_at as unknown as Date }); + if (newerPost) cands.push({ type: "image", aweme_id: newerPost.aweme_id, created_at: newerPost.created_at as unknown as Date }); + if (cands.length === 0) return undefined; + // nearest newer -> minimal created_at + cands.sort((a, b) => +a.created_at - +b.created_at); + const h = cands[0]; + return { type: h.type, aweme_id: h.aweme_id, created_at: h.created_at.toISOString() }; + })(); + + const pickNext = (() => { + const cands: { type: "video" | "image"; aweme_id: string; created_at: Date }[] = []; + if (olderVideo) cands.push({ type: "video", aweme_id: olderVideo.aweme_id, created_at: olderVideo.created_at as unknown as Date }); + if (olderPost) cands.push({ type: "image", aweme_id: olderPost.aweme_id, created_at: olderPost.created_at as unknown as Date }); + if (cands.length === 0) return undefined; + // nearest older -> maximal created_at among older + cands.sort((a, b) => +b.created_at - +a.created_at); + const h = cands[0]; + return { type: h.type, aweme_id: h.aweme_id, created_at: h.created_at.toISOString() }; + })(); + + return NextResponse.json({ prev: pickPrev ?? null, next: pickNext ?? null }); +} diff --git a/app/aweme/[awemeId]/Client.tsx b/app/aweme/[awemeId]/Client.tsx index c8bf178..8cf1910 100644 --- a/app/aweme/[awemeId]/Client.tsx +++ b/app/aweme/[awemeId]/Client.tsx @@ -1,5 +1,6 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; import { ChevronLeft, ChevronRight, @@ -14,11 +15,67 @@ import { MessageSquareText, RotateCcw, RotateCw, + Maximize2, + Minimize, } from "lucide-react"; type User = { nickname: string; avatar_url: string | null }; type Comment = { cid: string; text: string; digg_count: number; created_at: string | Date; user: User }; +// 处理评论文本中的表情占位符 +function parseCommentText(text: string): (string | { type: "emoji"; name: string })[] { + const parts: (string | { type: "emoji"; name: string })[] = []; + const regex = /\[([^\]]+)\]/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = regex.exec(text)) !== null) { + // 添加表情前的文本 + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); + } + // 添加表情 + parts.push({ type: "emoji", name: match[1] }); + lastIndex = regex.lastIndex; + } + + // 添加剩余文本 + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return parts; +} + +// 渲染评论文本(包含表情) +function CommentText({ text }: { text: string }) { + const parts = parseCommentText(text); + + return ( + <> + {parts.map((part, idx) => { + if (typeof part === "string") { + return {part}; + } + return ( + {part.name} { + // 如果图片加载失败,显示原始文本 + e.currentTarget.style.display = "none"; + const textNode = document.createTextNode(`[${part.name}]`); + e.currentTarget.parentNode?.insertBefore(textNode, e.currentTarget); + }} + /> + ); + })} + + ); +} + type VideoData = { type: "video"; aweme_id: string; @@ -45,26 +102,53 @@ type ImageData = { const SEGMENT_MS = 5000; // 图文每段 5s -export default function AwemeDetailClient(props: { data: VideoData | ImageData }) { - const { data } = props; +type Neighbors = { prev: { aweme_id: string } | null; next: { aweme_id: string } | null }; + +export default function AwemeDetailClient(props: { data: VideoData | ImageData; neighbors?: Neighbors }) { + const { data, neighbors } = props; const isVideo = data.type === "video"; + const router = useRouter(); // ====== 布局 & 评论 ====== - const [open, setOpen] = useState(false); // 评论是否展开(竖屏为 bottom sheet;横屏为并排分栏) + const [open, setOpen] = useState(() => { + // 从 localStorage 读取评论区状态,默认 false + if (typeof window === "undefined") return false; + const saved = localStorage.getItem("aweme_player_comments_open"); + if (!saved) return false; + return saved === "true"; + }); // 评论是否展开(竖屏为 bottom sheet;横屏为并排分栏) const comments = useMemo(() => data.comments ?? [], [data]); // ====== 媒体引用 ====== const mediaContainerRef = useRef(null); const videoRef = useRef(null); const audioRef = useRef(null); + const wheelCooldownRef = useRef(0); + const backgroundCanvasRef = useRef(null); // ====== 统一控制状态 ====== const [isPlaying, setIsPlaying] = useState(true); // 视频=播放;图文=是否自动切换 const [isFullscreen, setIsFullscreen] = useState(false); - const [volume, setVolume] = useState(1); // 视频音量 / 图文BGM音量 - const [rate, setRate] = useState(1); // 仅视频使用 + const [volume, setVolume] = useState(() => { + // 从 localStorage 读取音量,默认 1 + if (typeof window === "undefined") return 1; + const saved = localStorage.getItem("aweme_player_volume"); + if (!saved) return 1; + const parsed = parseFloat(saved); + return Number.isNaN(parsed) ? 1 : Math.max(0, Math.min(1, parsed)); + }); + const [rate, setRate] = useState(() => { + // 从 localStorage 读取倍速,默认 1 + if (typeof window === "undefined") return 1; + const saved = localStorage.getItem("aweme_player_rate"); + if (!saved) return 1; + const parsed = parseFloat(saved); + return Number.isNaN(parsed) ? 1 : parsed; + }); const [progress, setProgress] = useState(0); // 0..1 总进度 const [rotation, setRotation] = useState(0); // 视频旋转角度:0/90/180/270 + const [progressRestored, setProgressRestored] = useState(false); // 标记进度是否已恢复 + const [objectFit, setObjectFit] = useState<"contain" | "cover">("contain"); // 媒体显示模式 // ====== 图文专用(分段) ====== const images = (data as any).images as ImageData["images"] | undefined; @@ -80,6 +164,104 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData } useEffect(() => { idxRef.current = idx; }, [idx]); + // ====== 持久化音量到 localStorage ====== + useEffect(() => { + if (typeof window === "undefined") return; + localStorage.setItem("aweme_player_volume", volume.toString()); + }, [volume]); + + // ====== 持久化倍速到 localStorage ====== + useEffect(() => { + if (typeof window === "undefined") return; + localStorage.setItem("aweme_player_rate", rate.toString()); + }, [rate]); + + // ====== 持久化评论区状态到 localStorage ====== + useEffect(() => { + if (typeof window === "undefined") return; + localStorage.setItem("aweme_player_comments_open", open.toString()); + }, [open]); + + // ====== 恢复视频播放进度(带有效期) ====== + useEffect(() => { + if (!isVideo || progressRestored) return; + const v = videoRef.current; + if (!v) return; + + const onLoadedMetadata = () => { + if (progressRestored) return; + + try { + const key = `aweme_progress_${data.aweme_id}`; + const saved = localStorage.getItem(key); + if (!saved) { + setProgressRestored(true); + return; + } + + const { time, timestamp } = JSON.parse(saved); + const now = Date.now(); + const fiveMinutes = 5 * 60 * 1000; + + // 检查是否在 5 分钟有效期内 + if (now - timestamp < fiveMinutes && time > 1 && time < v.duration - 1) { + v.currentTime = time; + console.log(`恢复播放进度: ${Math.round(time)}s`); + } else if (now - timestamp >= fiveMinutes) { + // 过期则清除 + localStorage.removeItem(key); + } + } catch (e) { + console.error("恢复播放进度失败", e); + } + + setProgressRestored(true); + }; + + if (v.readyState >= 1) { + // 元数据已加载 + onLoadedMetadata(); + } else { + v.addEventListener("loadedmetadata", onLoadedMetadata, { once: true }); + return () => v.removeEventListener("loadedmetadata", onLoadedMetadata); + } + }, [isVideo, data.aweme_id, progressRestored]); + + // ====== 实时保存视频播放进度到 localStorage ====== + useEffect(() => { + if (!isVideo) return; + const v = videoRef.current; + if (!v) return; + + const saveProgress = () => { + if (!v.duration || Number.isNaN(v.duration) || v.currentTime < 1) return; + + try { + const key = `aweme_progress_${data.aweme_id}`; + const value = JSON.stringify({ + time: v.currentTime, + timestamp: Date.now(), + }); + localStorage.setItem(key, value); + } catch (e) { + console.error("保存播放进度失败", e); + } + }; + + // 每 2 秒保存一次进度 + const interval = setInterval(saveProgress, 2000); + + // 页面卸载时也保存一次 + const onBeforeUnload = () => saveProgress(); + window.addEventListener("beforeunload", onBeforeUnload); + + return () => { + clearInterval(interval); + window.removeEventListener("beforeunload", onBeforeUnload); + saveProgress(); // 组件卸载时保存 + }; + }, [isVideo, data.aweme_id]); + // ====== 视频:进度/播放/倍速/音量 ====== useEffect(() => { if (!isVideo) return; @@ -143,6 +325,30 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData } return () => document.removeEventListener("fullscreenchange", onFsChange); }, []); + // ====== 监听浏览器返回事件,尝试关闭页面 ====== + useEffect(() => { + // 在 history 中添加一个状态,用于拦截返回事件 + window.history.pushState({ interceptBack: true }, ""); + + const handlePopState = (e: PopStateEvent) => { + // 尝试关闭窗口 + window.close(); + + // 如果关闭失败(100ms 后页面仍可见),则导航到首页 + setTimeout(() => { + if (!document.hidden) { + router.push("/"); + } + }, 100); + }; + + window.addEventListener("popstate", handlePopState); + + return () => { + window.removeEventListener("popstate", handlePopState); + }; + }, [router]); + // ====== 图文:自动切页(消除“闪回”)====== useEffect(() => { if (isVideo || !images?.length) return; @@ -241,14 +447,14 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData } if (isVideo) { const v = videoRef.current; if (!v) return; - if (v.paused) await v.play(); + if (v.paused) await v.play().catch(() => { }); else v.pause(); return; } const el = audioRef.current; if (!isPlaying) { setIsPlaying(true); - try { await el?.play(); } catch { } + try { await el?.play().catch(() => { }); } catch { } } else { setIsPlaying(false); el?.pause(); @@ -256,10 +462,8 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData } }; const toggleFullscreen = () => { - const el = mediaContainerRef.current; - if (!el) return; if (!document.fullscreenElement) { - el.requestFullscreen().catch(() => { }); + document.body.requestFullscreen().catch(() => { }); } else { document.exitFullscreen().catch(() => { }); } @@ -303,8 +507,132 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData } : "landscape:w-0", ].join(" "); + // ====== 预取上/下一条路由,提高切换流畅度 ====== + useEffect(() => { + if (!neighbors) return; + if (neighbors.next) router.prefetch(`/aweme/${neighbors.next.aweme_id}`); + if (neighbors.prev) router.prefetch(`/aweme/${neighbors.prev.aweme_id}`); + }, [neighbors?.next?.aweme_id, neighbors?.prev?.aweme_id]); + + // ====== 鼠标滚轮切换上一条/下一条(纵向滚动) ====== + useEffect(() => { + const el = mediaContainerRef.current; + if (!el) return; + const onWheel = (e: WheelEvent) => { + // 避免缩放/横向滚动干扰 + if (e.ctrlKey) return; + const now = performance.now(); + if (now - wheelCooldownRef.current < 700) return; // 冷却 700ms + const dy = e.deltaY; + if (Math.abs(dy) < 40) return; // 过滤轻微滚轮 + + // 有上一条/下一条才拦截默认行为 + if ((dy > 0 && neighbors?.next) || (dy < 0 && neighbors?.prev)) { + e.preventDefault(); + } + + if (dy > 0 && neighbors?.next) { + wheelCooldownRef.current = now; + router.push(`/aweme/${neighbors.next.aweme_id}`); + } else if (dy < 0 && neighbors?.prev) { + wheelCooldownRef.current = now; + router.push(`/aweme/${neighbors.prev.aweme_id}`); + } + }; + // 需非被动监听以便 preventDefault + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel as any); + }, [neighbors?.next?.aweme_id, neighbors?.prev?.aweme_id]); + + // ====== 动态模糊背景:每 0.2s 截取当前媒体内容绘制到背景 canvas ====== + useEffect(() => { + const canvas = backgroundCanvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + // 更新 canvas 尺寸以匹配视口 + const updateCanvasSize = () => { + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + }; + updateCanvasSize(); + window.addEventListener("resize", updateCanvasSize); + + // 绘制媒体内容到 canvas(cover 策略) + const drawMediaToCanvas = () => { + if (!ctx) return; + + let sourceElement: HTMLVideoElement | HTMLImageElement | null = null; + + // 获取当前媒体元素 + if (isVideo) { + sourceElement = videoRef.current; + } else { + // 对于图文,获取当前显示的图片 + const scroller = scrollerRef.current; + if (scroller) { + const currentImgContainer = scroller.children[idx] as HTMLElement; + if (currentImgContainer) { + sourceElement = currentImgContainer.querySelector("img"); + } + } + } + + if (!sourceElement || (sourceElement instanceof HTMLVideoElement && sourceElement.readyState < 2)) { + return; + } + + const canvasWidth = canvas.width; + const canvasHeight = canvas.height; + const sourceWidth = sourceElement instanceof HTMLVideoElement ? sourceElement.videoWidth : sourceElement.naturalWidth; + const sourceHeight = sourceElement instanceof HTMLVideoElement ? sourceElement.videoHeight : sourceElement.naturalHeight; + + if (!sourceWidth || !sourceHeight) return; + + // 计算 cover 模式的尺寸和位置 + const canvasRatio = canvasWidth / canvasHeight; + const sourceRatio = sourceWidth / sourceHeight; + + let drawWidth: number, drawHeight: number, offsetX: number, offsetY: number; + + if (canvasRatio > sourceRatio) { + // canvas 更宽,按宽度填充 + drawWidth = canvasWidth; + drawHeight = canvasWidth / sourceRatio; + offsetX = 0; + offsetY = (canvasHeight - drawHeight) / 2; + } else { + // canvas 更高,按高度填充 + drawHeight = canvasHeight; + drawWidth = canvasHeight * sourceRatio; + offsetX = (canvasWidth - drawWidth) / 2; + offsetY = 0; + } + + // 清空画布并绘制 + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + ctx.drawImage(sourceElement, offsetX, offsetY, drawWidth, drawHeight); + }; + + const intervalId = setInterval(drawMediaToCanvas, 20); + + return () => { + clearInterval(intervalId); + window.removeEventListener("resize", updateCanvasSize); + }; + }, [isVideo, idx]); + return (
+ {/* 动态模糊背景 canvas */} + + {/* 横屏下使用并排布局;竖屏下为单栏(评论为 bottom sheet) */}
{/* 主媒体区域 */} @@ -317,11 +645,11 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData } ref={videoRef} src={(data as VideoData).video_url} className={[ - // 旋转 0/180:充满容器盒子,用 object-contain; - // 旋转 90/270:用中心定位 + 100vh/100vw + object-cover,保证铺满全屏 + // 旋转 0/180:充满容器盒子; + // 旋转 90/270:用中心定位 + 100vh/100vw,保证铺满全屏 rotation % 180 === 0 - ? "absolute inset-0 h-full w-full object-contain bg-black/70" - : "absolute top-1/2 left-1/2 h-[100vw] w-[100vh] object-contain bg-black/70", + ? `absolute inset-0 h-full w-full object-${objectFit} bg-black/70 cursor-pointer` + : `absolute top-1/2 left-1/2 h-[100vw] w-[100vh] object-${objectFit} bg-black/70 cursor-pointer`, ].join(" ")} style={{ transform: @@ -332,7 +660,7 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData } }} playsInline autoPlay - loop + loop onClick={togglePlay} /> ) : (
@@ -347,7 +675,7 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData } style={{ aspectRatio: img.width && img.height ? `${img.width}/${img.height}` : undefined }} > {/* eslint-disable-next-line @next/next/no-img-element */} - image + image
))}
@@ -506,6 +834,14 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData }
+
-

{c.text}

+

+ +

{c.digg_count} diff --git a/app/aweme/[awemeId]/emojis.ts b/app/aweme/[awemeId]/emojis.ts new file mode 100644 index 0000000..f716652 --- /dev/null +++ b/app/aweme/[awemeId]/emojis.ts @@ -0,0 +1,3 @@ +export const emojiList = [ + "微笑", "色", "发呆", "酷拽", "抠鼻", "流泪", "捂脸", "发怒", "呲牙", "尬笑", "害羞", "调皮", "舔屏", "看", "爱心", "比心", "赞", "鼓掌", "感谢", "抱抱你", "玫瑰", "尴尬流汗", "戳手手", "星星眼", "杀马特", "黄脸干杯", "抱紧自己", "拜拜", "热化了", "黄脸祈祷", "懵", "举手", "加功德", "摊手", "无语流汗", "续火花吧", "点火", "哭哭", "吐舌小狗", "送花", "爱心手", "贴贴", "灵机一动", "耶", "打脸", "大笑", "机智", "送心", "666", "闭嘴", "来看我", "一起加油", "哈欠", "震惊", "晕", "衰", "困", "疑问", "泣不成声", "小鼓掌", "大金牙", "偷笑", "石化", "思考", "吐血", "可怜", "嘘", "撇嘴", "笑哭", "奸笑", "得意", "憨笑", "坏笑", "抓狂", "泪奔", "钱", "恐惧", "愉快", "快哭了", "翻白眼", "互粉", "我想静静", "委屈", "鄙视", "飞吻", "再见", "紫薇别走", "听歌", "求抱抱", "绝望的凝视", "不失礼貌的微笑", "不看", "裂开", "干饭人", "庆祝", "吐舌", "呆无辜", "白眼", "猪头", "冷漠", "暗中观察", "二哈", "菜狗", "黑脸", "展开说说", "蜜蜂狗", "柴犬", "摸头", "皱眉", "擦汗", "红脸", "做鬼脸", "强", "如花", "吐", "惊喜", "敲打", "奋斗", "吐彩虹", "大哭", "嘿哈", "惊恐", "囧", "难过", "斜眼", "阴险", "悠闲", "咒骂", "吃瓜群众", "绿帽子", "敢怒不敢言", "求求了", "眼含热泪", "叹气", "好开心", "不是吧", "鞠躬", "躺平", "九转大肠", "不你不想", "一头乱麻", "kisskiss", "你不大行", "噢买尬", "宕机", "苦涩", "逞强落泪", "求机位-黄脸", "求机位3", "点赞", "精选", "强壮", "碰拳", "OK", "击掌", "左上", "握手", "抱拳", "勾引", "拳头", "弱", "胜利", "右边", "左边", "嘴唇", "心碎", "凋谢", "愤怒", "垃圾", "啤酒", "咖啡", "蛋糕", "礼物", "撒花", "加一", "减一", "okk", "V5", "绝", "给力", "红包", "屎", "发", "18禁", "炸弹", "西瓜", "加鸡腿", "握爪", "太阳", "月亮", "给跪了", "蕉绿", "扎心", "胡瓜", "打call", "栓Q", "雪花", "圣诞树", "平安果", "圣诞帽", "气球", "烟花", "福", "candy", "糖葫芦", "鞭炮", "元宝", "灯笼", "锦鲤", "巧克力", "戒指", "棒棒糖", "纸飞机", "粽子" +] \ No newline at end of file diff --git a/app/aweme/[awemeId]/page.tsx b/app/aweme/[awemeId]/page.tsx index eed8ff4..3e99de9 100644 --- a/app/aweme/[awemeId]/page.tsx +++ b/app/aweme/[awemeId]/page.tsx @@ -63,6 +63,32 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI })), }; + // Compute prev/next neighbors by created_at across videos and image posts + const currentCreatedAt = (isVideo ? video!.created_at : post!.created_at) as unknown as Date; + const [newerVideo, newerPost, olderVideo, olderPost] = await Promise.all([ + prisma.video.findFirst({ where: { created_at: { gt: currentCreatedAt } }, orderBy: { created_at: "asc" }, select: { aweme_id: true, created_at: true } }), + prisma.imagePost.findFirst({ where: { created_at: { gt: currentCreatedAt } }, orderBy: { created_at: "asc" }, select: { aweme_id: true, created_at: true } }), + prisma.video.findFirst({ where: { created_at: { lt: currentCreatedAt } }, orderBy: { created_at: "desc" }, select: { aweme_id: true, created_at: true } }), + prisma.imagePost.findFirst({ where: { created_at: { lt: currentCreatedAt } }, orderBy: { created_at: "desc" }, select: { aweme_id: true, created_at: true } }), + ]); + const pickPrev = (() => { + const cands: { aweme_id: string; created_at: Date }[] = []; + if (newerVideo) cands.push({ aweme_id: newerVideo.aweme_id, created_at: newerVideo.created_at as unknown as Date }); + if (newerPost) cands.push({ aweme_id: newerPost.aweme_id, created_at: newerPost.created_at as unknown as Date }); + if (cands.length === 0) return null; + cands.sort((a, b) => +a.created_at - +b.created_at); + return { aweme_id: cands[0].aweme_id }; + })(); + const pickNext = (() => { + const cands: { aweme_id: string; created_at: Date }[] = []; + if (olderVideo) cands.push({ aweme_id: olderVideo.aweme_id, created_at: olderVideo.created_at as unknown as Date }); + if (olderPost) cands.push({ aweme_id: olderPost.aweme_id, created_at: olderPost.created_at as unknown as Date }); + if (cands.length === 0) return null; + cands.sort((a, b) => +b.created_at - +a.created_at); + return { aweme_id: cands[0].aweme_id }; + })(); + const neighbors: { prev: { aweme_id: string } | null; next: { aweme_id: string } | null } = { prev: pickPrev, next: pickNext }; + return (
{/* 顶部条改为悬浮在媒体区域之上,避免 sticky 造成 Y 方向滚动条 */} @@ -72,7 +98,7 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI className="inline-flex items-center justify-center w-9 h-9 rounded-full bg-white/15 text-white border border-white/20 backdrop-blur hover:bg-white/25" />
- + ); } diff --git a/app/components/BackButton.tsx b/app/components/BackButton.tsx index 64e8caa..a1f3015 100644 --- a/app/components/BackButton.tsx +++ b/app/components/BackButton.tsx @@ -14,8 +14,8 @@ type BackButtonProps = { /** * BackButton - * - Primary: behaves like browser back (router.back()), preserving previous page state (e.g., scroll, filters) - * - Fallback: if no history entry exists (e.g., opened directly), navigates to '/' + * - Primary: attempts to close the current window/tab (window.close()) + * - Fallback: if close fails (e.g., not opened by script), navigates to '/' * - Uses so that Ctrl/Cmd-click or middle-click opens the fallback URL in a new tab naturally */ export default function BackButton({ className, ariaLabel = '返回', hrefFallback = '/', children }: BackButtonProps) { @@ -25,13 +25,21 @@ export default function BackButton({ className, ariaLabel = '返回', hrefFallba // Respect modifier clicks (new tab/window) and non-left clicks if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; - // Prefer SPA back when we have some history to go back to - if (typeof window !== 'undefined' && window.history.length > 1) { - e.preventDefault(); - router.back(); + e.preventDefault(); + + // Try to close the window first + if (typeof window !== 'undefined') { + window.close(); + + // If window.close() didn't work (window still open after a short delay), + // navigate to the fallback URL + setTimeout(() => { + if (!document.hidden) { + router.push(hrefFallback); + } + }, 80); } - // else: allow default to navigate to fallback - }, [router]); + }, [router, hrefFallback]); return ( ( - +
| null = null +let context: BrowserContext | null = null +let refCount = 0 +let idleCloseTimer: NodeJS.Timeout | null = null + +const USER_DATA_DIR = 'chrome-profile/douyin' +const DEFAULT_OPTIONS = { headless: true } as const + +async function launchContext(): Promise { + const ctx = await chromium.launchPersistentContext(USER_DATA_DIR, DEFAULT_OPTIONS) + // When the context is closed externally, reset manager state + ctx.on('close', () => { + context = null + contextPromise = null + refCount = 0 + if (idleCloseTimer) { + clearTimeout(idleCloseTimer) + idleCloseTimer = null + } + }) + return ctx +} + +export async function acquireBrowserContext(): Promise { + // Cancel any pending idle close if a new consumer arrives + if (idleCloseTimer) { + clearTimeout(idleCloseTimer) + idleCloseTimer = null + } + + if (context) { + refCount += 1 + return context + } + + if (!contextPromise) { + contextPromise = launchContext() + } + context = await contextPromise + refCount += 1 + return context +} + +export async function releaseBrowserContext(options?: { idleMillis?: number }): Promise { + const idleMillis = options?.idleMillis ?? 15_000 + refCount = Math.max(0, refCount - 1) + + if (refCount > 0 || !context) return + + // Delay the close to allow bursty workloads to reuse the context + if (idleCloseTimer) { + clearTimeout(idleCloseTimer) + idleCloseTimer = null + } + idleCloseTimer = setTimeout(async () => { + try { + if (context && refCount === 0) { + await context.close() + } + } finally { + context = null + contextPromise = null + idleCloseTimer = null + } + }, idleMillis) +} diff --git a/app/fetcher/index.ts b/app/fetcher/index.ts index 71f4d46..1b381d6 100644 --- a/app/fetcher/index.ts +++ b/app/fetcher/index.ts @@ -1,5 +1,5 @@ // src/scrapeDouyin.ts -import { BrowserContext, chromium, Page, type Response } from 'playwright'; +import { BrowserContext, Page, chromium, type Response } from 'playwright'; import { prisma } from '@/lib/prisma'; import { uploadFile, generateUniqueFileName } from '@/lib/minio'; import { createCamelCompatibleProxy } from '@/app/fetcher/utils'; @@ -8,6 +8,7 @@ import { pickBestPlayAddr, extractFirstFrame } from '@/app/fetcher/media'; import { handleImagePost } from '@/app/fetcher/uploader'; import { saveToDB, saveImagePostToDB } from '@/app/fetcher/persist'; import chalk from 'chalk'; +import { acquireBrowserContext, releaseBrowserContext } from '@/app/fetcher/browser'; const DETAIL_PATH = '/aweme/v1/web/aweme/detail/'; const COMMENT_PATH = '/aweme/v1/web/comment/list/'; @@ -33,11 +34,21 @@ async function readPostMem(context: BrowserContext, page: Page) { } -export async function scrapeDouyin(url: string) { - const browser = await chromium.launch({ headless: true }); - console.log(chalk.blue('🚀 启动 Chromium 浏览器...')); +export class ScrapeError extends Error { + constructor( + message: string, + public statusCode: number = 500, + public code?: string + ) { + super(message); + this.name = 'ScrapeError'; + } +} - const context = await chromium.launchPersistentContext('chrome-profile/douyin', { headless: false }); +export async function scrapeDouyin(url: string) { + console.log(chalk.blue('🚀 启动共享 Chromium 浏览器...')); + + const context = await acquireBrowserContext(); const page = await context.newPage(); console.log(chalk.cyan(`📄 正在访问: ${chalk.underline(url)}`)); @@ -82,6 +93,13 @@ export async function scrapeDouyin(url: string) { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + // 查找页面中是否存在 "视频不存在" 的提示 + const isNotFound = await page.locator('text=视频不存在').count().then(count => count > 0).catch(() => false); + if (isNotFound) { + console.error(chalk.red('✗ 视频不存在或已被删除')); + throw new ScrapeError('视频不存在或已被删除', 404, 'VIDEO_NOT_FOUND'); + } + try { // 优先尝试从内存读取图文数据 let { aweme, comments } = await readPostMem(context, page); @@ -113,8 +131,8 @@ export async function scrapeDouyin(url: string) { const firstType = await firstTypePromise; if (!firstType) { - console.error(chalk.red('✗ 既无法从内存读取数据,也无法从网络获得数据')); - throw new Error('既无法从内存读取数据,也无法从网络获得数据。'); + console.error(chalk.red('✗ 既无法从内存读取数据,也无法从网络获得数据')); + throw new ScrapeError('无法获取作品数据,可能是网络问题或作品已下架', 404, 'NO_DATA'); } console.log(chalk.cyan(`📡 检测到作品类型: ${chalk.bold(firstType.key === 'post' ? '图文' : '视频')}`)); @@ -129,14 +147,14 @@ export async function scrapeDouyin(url: string) { if (firstType.key === 'post') { // 图文作品 const postJson = await safeJson(firstType.response); - if (!postJson?.aweme_list?.length) throw new Error('图文作品响应为空'); + if (!postJson?.aweme_list?.length) throw new ScrapeError('图文作品响应为空', 404, 'EMPTY_POST_RESPONSE'); const currentURL = page.url(); const target_aweme_id = currentURL.split('/').at(-1); const awemeList = postJson.aweme_list as unknown as DouyinImageAweme[]; let aweme = awemeList.find((pt: DouyinImageAweme) => pt.aweme_id === target_aweme_id); if (!aweme) { - throw new Error('既无法从内存读取数据,Post 列表中也不包含需要爬取的作品。'); + throw new ScrapeError('无法找到目标作品,可能已被删除', 404, 'POST_NOT_FOUND'); } const uploads = await handleImagePost(context, aweme); @@ -191,12 +209,36 @@ export async function scrapeDouyin(url: string) { console.log(chalk.green.bold('✓ 视频作品保存成功')); return { type: "video", ...saved }; } else { - throw new Error('无法判定作品类型(未命中详情或图文接口)'); + throw new ScrapeError('无法判定作品类型,接口响应异常', 500, 'UNKNOWN_TYPE'); } + } catch (error) { + // 如果是我们自定义的错误,直接抛出 + if (error instanceof ScrapeError) { + throw error; + } + + // 处理其他类型的错误 + const errMsg = (error as Error)?.message || String(error); + console.error(chalk.red(`✗ 爬取失败: ${errMsg}`)); + + // 根据错误类型返回不同的状态码 + if (errMsg.includes('timeout') || errMsg.includes('超时')) { + throw new ScrapeError('请求超时,请稍后重试', 408, 'TIMEOUT'); + } + if (errMsg.includes('页面内存数据中未找到作品详情')) { + throw new ScrapeError('作品数据加载失败', 404, 'DATA_NOT_LOADED'); + } + if (errMsg.includes('net::')) { + throw new ScrapeError('网络连接失败', 503, 'NETWORK_ERROR'); + } + + // 默认服务器错误 + throw new ScrapeError(errMsg || '爬取过程中发生未知错误', 500, 'UNKNOWN_ERROR'); } finally { console.log(chalk.gray('🧹 清理资源...')); - await context.close(); - await browser.close(); + try { await page.close({ runBeforeUnload: true }); } catch {} + // 仅释放共享上下文的引用,不直接关闭窗口 + await releaseBrowserContext(); await prisma.$disconnect(); console.log(chalk.gray('✓ 资源清理完成')); } diff --git a/app/fetcher/network.ts b/app/fetcher/network.ts index a510695..95a4cc8 100644 --- a/app/fetcher/network.ts +++ b/app/fetcher/network.ts @@ -22,7 +22,7 @@ export async function downloadBinary( context: BrowserContext, url: string ): Promise<{ buffer: Buffer; contentType: string; ext: string }> { - console.log('Download bin:', url); + console.log('下载:', url); const headers = { referer: url, diff --git a/app/fetcher/route.ts b/app/fetcher/route.ts index f46cead..3a2034c 100644 --- a/app/fetcher/route.ts +++ b/app/fetcher/route.ts @@ -1,17 +1,49 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' -import { scrapeDouyin } from '.'; +import { scrapeDouyin, ScrapeError } from '.'; async function handleDouyinScrape(req: NextRequest) { const { searchParams } = new URL(req.url); const videoUrl = searchParams.get('url'); + if (!videoUrl) { - return NextResponse.json({ error: '缺少视频URL' }, { status: 400 }); + return NextResponse.json( + { error: '缺少视频URL', code: 'MISSING_URL' }, + { status: 400 } + ); } - // 调用爬虫函数 - const result = await scrapeDouyin(videoUrl); - return NextResponse.json(result); + try { + // 调用爬虫函数 + const result = await scrapeDouyin(videoUrl); + return NextResponse.json({ + success: true, + data: result + }); + } catch (error) { + // 处理自定义的 ScrapeError + if (error instanceof ScrapeError) { + return NextResponse.json( + { + success: false, + error: error.message, + code: error.code + }, + { status: error.statusCode } + ); + } + + // 处理未知错误 + console.error('未捕获的错误:', error); + return NextResponse.json( + { + success: false, + error: '服务器内部错误', + code: 'INTERNAL_ERROR' + }, + { status: 500 } + ); + } } export const GET = handleDouyinScrape diff --git a/app/tasks/page.tsx b/app/tasks/page.tsx index cf6a0e0..e069cd5 100644 --- a/app/tasks/page.tsx +++ b/app/tasks/page.tsx @@ -32,6 +32,7 @@ export default function TasksPage() { const [tasks, setTasks] = useState([]); const controllers = useRef>(new Map()); const [openDetails, setOpenDetails] = useState>(new Set()); + const [, setTick] = useState(0); // 用于强制更新计时显示 const inProgressUrls = useMemo( () => new Set(tasks.filter(t => t.status === "pending" || t.status === "running").map(t => t.url)), @@ -44,12 +45,13 @@ export default function TasksPage() { setTasks((prev) => { const existing = new Set(prev.map((t) => t.id)); const notDuplicated = urls.filter(u => !inProgressUrls.has(u)); - const next: Task[] = [...prev]; + const newTasks: Task[] = []; for (const url of notDuplicated) { const id = `${now}-${Math.random().toString(36).slice(2, 8)}`; - next.push({ id, url, status: "pending" }); + newTasks.push({ id, url, status: "pending" }); } - return next; + // 新任务添加到最前面 + return [...newTasks, ...prev]; }); }, [inProgressUrls]); @@ -69,14 +71,18 @@ export default function TasksPage() { if (controllers.current.has(task.id)) return; const ctrl = new AbortController(); controllers.current.set(task.id, ctrl); - setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "running", startedAt: Date.now() } : t)); + setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "running", startedAt: Date.now(), error: undefined } : t)); try { const res = await fetch(`/fetcher?url=${encodeURIComponent(task.url)}`, { signal: ctrl.signal, method: "GET" }); + const data = await res.json().catch(() => null); + if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(text || `请求失败: ${res.status}`); + // 使用后端返回的结构化错误信息 + const errorMsg = data?.error || `请求失败: ${res.status}`; + const errorCode = data?.code || 'UNKNOWN'; + throw new Error(`${errorMsg} (${errorCode})`); } - const data = await res.json().catch(() => undefined); + setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "success", finishedAt: Date.now(), result: data } : t)); } catch (err: any) { const msg = err?.name === 'AbortError' ? '已取消' : (err?.message || String(err)); @@ -92,12 +98,33 @@ export default function TasksPage() { pending.forEach((t) => startTask(t)); }, [tasks, startTask]); + // 定时器更新运行中任务的耗时显示 + useEffect(() => { + const hasRunningTasks = tasks.some(t => t.status === "running"); + if (!hasRunningTasks) return; + + const timer = setInterval(() => { + setTick(prev => prev + 1); + }, 1000); // 每秒更新一次 + + return () => clearInterval(timer); + }, [tasks]); + const cancelTask = useCallback((id: string) => { const ctrl = controllers.current.get(id); if (ctrl) ctrl.abort(); controllers.current.delete(id); }, []); + const retryTask = useCallback((taskId: string) => { + setTasks(prev => prev.map(t => { + if (t.id === taskId) { + return { ...t, status: "pending" as TaskStatus, error: undefined, result: undefined }; + } + return t; + })); + }, []); + const clearFinished = useCallback(() => { setTasks(prev => prev.filter(t => t.status === "pending" || t.status === "running")); }, []); @@ -203,7 +230,25 @@ export default function TasksPage() {
-
+
+ {t.status === 'success' && t.result?.data?.aweme_id && ( + + 查看作品 + + )} + {t.status === 'error' && ( + + )} {t.status === 'running' && (