Team Ai
Apppublic

turbohung/vibecoding

sourceHugging Faceupdated 4mo agoView on Hugging Face
1likes
core.py807 linesDownload Raw Back to src
1"""2抖音视频解析核心模块 (Playwright版本)3使用浏览器自动化绕过反爬,支持短链接和长链接4"""5 6import re7import json8import asyncio9from typing import Optional, Dict, List, Tuple10from urllib.parse import urlparse, parse_qs11from playwright.async_api import async_playwright12 13# ============================================================14# 正则模式 — 覆盖所有已知抖音链接格式15# ============================================================16SHORT_PATTERN = re.compile(r'v\.douyin\.com/([A-Za-z0-9_\-]+)/?')17LONG_PATTERN = re.compile(r'(?:www\.)?douyin\.com/video/(\d+)')18NOTE_PATTERN = re.compile(r'(?:www\.)?douyin\.com/note/(\d+)')19USER_VIDEO_PATTERN = re.compile(r'(?:www\.)?douyin\.com/user/\S*?video/(\d+)')20USER_MODAL_PATTERN = re.compile(r'(?:www\.)?douyin\.com/user/\S*\?.*modal_id=(\d+)')21SHARE_PATTERN = re.compile(r'(?:www\.)?douyin\.com/share/video/(\d+)')22IES_SHARE_PATTERN = re.compile(r'(?:www\.)?iesdouyin\.com/share/video/(\d+)')23SEARCH_MODAL_PATTERN = re.compile(r'douyin\.com/search/\S*\?.*modal_id=(\d+)')24DISCOVER_MODAL_PATTERN = re.compile(r'douyin\.com/discover\?.*modal_id=(\d+)')25FOLLOW_PATTERN = re.compile(r'douyin\.com/follow\?.*modal_id=(\d+)')26RAW_VIDEO_ID_PATTERN = re.compile(r'^(\d{15,20})$')27RENDER_DATA_PATTERN = re.compile(28    r'<script\s+id="RENDER_DATA"\s+type="application/json">(.*?)</script>',29    re.DOTALL30)31 32# 所有提取ID的正则(按优先级排列)33ALL_ID_PATTERNS: List[Tuple[str, re.Pattern]] = [34    ('long_video', LONG_PATTERN),35    ('note', NOTE_PATTERN),36    ('ies_share', IES_SHARE_PATTERN),37    ('share', SHARE_PATTERN),38    ('user_video', USER_VIDEO_PATTERN),39    ('user_modal', USER_MODAL_PATTERN),40    ('search_modal', SEARCH_MODAL_PATTERN),41    ('discover_modal', DISCOVER_MODAL_PATTERN),42    ('follow_modal', FOLLOW_PATTERN),43]44 45# 链接类型中文提示46LINK_TYPE_LABELS = {47    'short': '短链接(需浏览器跟随重定向)',48    'long_video': '视频链接',49    'note': '图文笔记',50    'share': '分享链接',51    'ies_share': '企业号分享链接',52    'user_video': '用户主页视频',53    'user_modal': '用户主页弹窗',54    'search_modal': '搜索结果弹窗',55    'discover_modal': '发现页弹窗',56    'follow_modal': '关注页弹窗',57    'modal_page': '精选/发现页(modal_id)',58    'raw_id': '裸视频ID',59    'unknown': '无法识别的格式',60}61 62# 查询参数中可能包含视频ID的key63VIDEO_ID_QUERY_KEYS = ['video_id', 'aweme_id', 'item_id', 'modal_id', 'id']64 65# 从混合文本中提取抖音URL66# 匹配带或不带 http 前缀的douyin链接,含路径中的特殊字符67DOUYIN_URL_IN_TEXT = re.compile(68    r'(?:https?://)?(?:v\.|www\.)?(?:ies)?douyin\.com/[A-Za-z0-9/?=&_\-\.%~@!#]+'69)70# 纯文本提取(不用 http 前缀)71DOUYIN_URL_BARE = re.compile(72    r'((?:v\.|www\.)?(?:ies)?douyin\.com/[A-Za-z0-9/?=&_\-\.%~@!#]+)'73)74# 检测是否包含抖音域名(用于兜底验证)75DOUYIN_DOMAIN = re.compile(r'(?:v\.|www\.)?(?:ies)?douyin\.com')76 77 78class DouyinParser:79    """抖音链接解析器 — 基于Playwright浏览器自动化"""80 81    # ---- 类属性(外部可引用) ----82    SHORT_PATTERN = SHORT_PATTERN83    LONG_PATTERN = LONG_PATTERN84    NOTE_PATTERN = NOTE_PATTERN85    USER_VIDEO_PATTERN = USER_VIDEO_PATTERN86    USER_MODAL_PATTERN = USER_MODAL_PATTERN87    SHARE_PATTERN = SHARE_PATTERN88    IES_SHARE_PATTERN = IES_SHARE_PATTERN89    RAW_VIDEO_ID_PATTERN = RAW_VIDEO_ID_PATTERN90 91    # ================================================================92    # 浏览器生命周期93    # ================================================================94 95    @classmethod96    async def _launch_browser(cls):97        """每次请求独立启动浏览器(避免事件循环冲突)"""98        playwright = await async_playwright().start()99        browser = await playwright.chromium.launch(100            headless=True,101            args=[102                '--no-sandbox',103                '--disable-setuid-sandbox',104                '--disable-blink-features=AutomationControlled',105            ],106        )107        return playwright, browser108 109    @classmethod110    async def _launch_context(cls, playwright, browser):111        """创建带反检测的浏览器上下文"""112        context = await browser.new_context(113            user_agent=(114                'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '115                'AppleWebKit/537.36 (KHTML, like Gecko) '116                'Chrome/131.0.0.0 Safari/537.36'117            ),118            viewport={'width': 1440, 'height': 900},119            locale='zh-CN',120            extra_http_headers={121                'Accept-Language': 'zh-CN,zh;q=0.9',122            },123        )124        # Hide automation traces125        await context.add_init_script('''126            Object.defineProperty(navigator, 'webdriver', {get: () => false});127            Object.defineProperty(navigator, 'plugins', {get: () => [1,2,3,4,5]});128            Object.defineProperty(navigator, 'languages', {get: () => ['zh-CN','zh','en']});129            window.chrome = {runtime: {}};130        ''')131        return context132 133    # ================================================================134    # 链接类型检测 & ID提取135    # ================================================================136 137    @classmethod138    def extract_douyin_url(cls, text: str) -> Optional[str]:139        """140        从混合文本中提取抖音链接。141 142        处理从抖音App复制的原始分享文本,如:143        "0.79 o@d.Ag 05/14 DUY:/ # 大模型 https://v.douyin.com/xxx/ 复制此链接..."144 145        返回干净链接,没有则返回 None。146        """147        text = text.strip()148 149        # 如果整个文本就是一个 URL,直接返回150        if text.startswith('http://') or text.startswith('https://'):151            url = text.split()[0] if ' ' in text else text152            url = url.rstrip('.,;:!?。,;:!?)')153            if 'douyin.com' in url or 'iesdouyin.com' in url:154                return url155            return None156 157        # 处理裸域名(v.douyin.com/xxx 无协议前缀)158        if text.startswith('v.douyin.com/') or text.startswith('douyin.com/') or text.startswith('www.douyin.com/'):159            url = 'https://' + text.split()[0].rstrip('.,;:!?。,;:!?)')160            return url161 162        # 从文本中提取抖音 URL163        urls = DOUYIN_URL_IN_TEXT.findall(text)164        if not urls:165            return None166 167        # 返回第一个匹配(大概率是要看的那个)168        url = urls[0].rstrip('.,;:!?。,;:!?)')169        return url170 171    @classmethod172    def detect_link_type(cls, url: str) -> str:173        """174        检测链接类型,返回类型标识符。175 176        支持的类型:177          short        — v.douyin.com 短链178          long_video   — douyin.com/video/123179          note         — douyin.com/note/123180          share        — douyin.com/share/video/123181          ies_share    — iesdouyin.com/share/video/123182          user_video   — douyin.com/user/xxx/video/123183          user_modal   — douyin.com/user/xxx?modal_id=123184          modal_page   — jingxuan/discover/search?modal_id=123185          raw_id       — 纯数字视频ID186          unknown      — 无法识别187        """188        url = url.strip()189 190        # 从混合文本中提取URL(处理从App复制的分享文案)191        if not url.startswith('http') and not url.startswith('v.douyin'):192            extracted = cls.extract_douyin_url(url)193            if extracted:194                url = extracted195 196        if RAW_VIDEO_ID_PATTERN.match(url):197            return 'raw_id'198 199        if SHORT_PATTERN.search(url):200            return 'short'201 202        for link_type, pattern in ALL_ID_PATTERNS:203            if pattern.search(url):204                return link_type205 206        # 查询参数兜底(jingxuan / discover / search 等)207        try:208            # 确保有 scheme 才能 urlparse209            if '://' not in url:210                url = 'https://' + url211            parsed = urlparse(url)212            params = parse_qs(parsed.query)213            for key in VIDEO_ID_QUERY_KEYS:214                if key in params and params[key][0].isdigit():215                    return 'modal_page'216        except Exception:217            pass218 219        # 最后尝试:是否包含抖音域名(可能是未知的新链接格式,但仍可尝试解析)220        if DOUYIN_DOMAIN.search(url):221            url = url if '://' in url else 'https://' + url222            # 尝试跟随看看能不能得到video_id223            return 'short'  # 当作短链接处理,尝试跟随重定向224 225        return 'unknown'226 227    @classmethod228    def link_type_label(cls, link_type: str) -> str:229        """链接类型 → 中文描述"""230        return LINK_TYPE_LABELS.get(link_type, link_type)231 232    @classmethod233    def extract_video_id(cls, url: str) -> Optional[str]:234        """235        从各种格式的抖音链接中提取视频ID(同步,不解析短链)。236 237        支持:238        - 混合文本(如: "复制打开抖音 https://v.douyin.com/xxx/ ...")239        - 裸ID:7628983823787627689240        - douyin.com/video/{id}241        - douyin.com/note/{id}242        - douyin.com/share/video/{id}243        - iesdouyin.com/share/video/{id}244        - douyin.com/user/xxx/video/{id}245        - douyin.com/user/xxx?modal_id={id}246        - douyin.com/jingxuan?modal_id={id}247        - URL查询参数 video_id / aweme_id / item_id / modal_id248        """249        url = url.strip()250 251        # 混合文本 → 先提取URL252        if not url.startswith('http') and not RAW_VIDEO_ID_PATTERN.match(url):253            extracted = cls.extract_douyin_url(url)254            if extracted:255                url = extracted256 257        # 裸视频ID(纯数字)258        m = RAW_VIDEO_ID_PATTERN.match(url)259        if m:260            return m.group(1)261 262        # 正则匹配263        for _type, pattern in ALL_ID_PATTERNS:264            match = pattern.search(url)265            if match:266                return match.group(1)267 268        # URL 查询参数兜底269        try:270            parsed = urlparse(url)271            params = parse_qs(parsed.query)272            for key in VIDEO_ID_QUERY_KEYS:273                if key in params:274                    val = params[key][0]275                    if val.isdigit():276                        return val277        except Exception:278            pass279 280        return None281 282    @classmethod283    async def parse_url(cls, url: str) -> Optional[str]:284        """285        统一入口:自动检测链接类型 + 提取视频ID。286        - 混合文本自动识别(如从App复制的分享文案)287        - 短链自动用 Playwright 跟随重定向288        返回 video_id,解析失败返回 None。289        """290        url = url.strip()291 292        # 如果不是纯URL/纯ID → 从混合文本中提取抖音链接293        if not url.startswith('http') and not url.startswith('v.douyin') and not RAW_VIDEO_ID_PATTERN.match(url):294            extracted = cls.extract_douyin_url(url)295            if extracted:296                url = extracted297 298        # 确保有 scheme(裸域名补 https://)299        if DOUYIN_DOMAIN.search(url) and '://' not in url:300            url = 'https://' + url301 302        link_type = cls.detect_link_type(url)303 304        if link_type == 'raw_id':305            return url306 307        if link_type == 'short':308            return await cls._resolve_short_link(url)309 310        return cls.extract_video_id(url)311 312    @classmethod313    async def _resolve_short_link(cls, url: str) -> Optional[str]:314        """Playwright 跟随短链重定向,提取 video_id。兜底:从页面 RENDER_DATA 提取。"""315        pw, browser = await cls._launch_browser()316        try:317            context = await browser.new_context(318                user_agent=(319                    'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) '320                    'AppleWebKit/605.1.15 (KHTML, like Gecko) '321                    'Version/16.0 Mobile/15E148 Safari/604.1'322                ),323                viewport={'width': 390, 'height': 844},324                locale='zh-CN',325            )326            page = await context.new_page()327 328            video_id = None329 330            async def on_response(resp):331                nonlocal video_id332                if resp.status in (301, 302, 200, 307, 308):333                    final_url = resp.url334                    for _type, pattern in ALL_ID_PATTERNS:335                        match = pattern.search(final_url)336                        if match:337                            video_id = match.group(1)338                            return339                    # 也检查查询参数340                    try:341                        from urllib.parse import urlparse as up, parse_qs as pq342                        params = pq(up(final_url).query)343                        for key in VIDEO_ID_QUERY_KEYS:344                            if key in params and params[key][0].isdigit():345                                video_id = params[key][0]346                                return347                    except Exception:348                        pass349 350            page.on('response', on_response)351            await page.goto(url, wait_until='domcontentloaded', timeout=20000)352            await page.wait_for_timeout(3000)353 354            # 如果响应拦截没拿到,尝试从最终 URL 提取355            if not video_id:356                final_url = page.url357                for _type, pattern in ALL_ID_PATTERNS:358                    match = pattern.search(final_url)359                    if match:360                        video_id = match.group(1)361                        break362                # 尝试从查询参数提取363                if not video_id:364                    try:365                        from urllib.parse import urlparse as up, parse_qs as pq366                        params = pq(up(final_url).query)367                        for key in VIDEO_ID_QUERY_KEYS:368                            if key in params and params[key][0].isdigit():369                                video_id = params[key][0]370                                break371                    except Exception:372                        pass373 374            # 兜底:从页面 RENDER_DATA 提取375            if not video_id:376                try:377                    content = await page.content()378                    match = RENDER_DATA_PATTERN.search(content)379                    if match:380                        import json381                        data = json.loads(match.group(1))382                        video_id = cls._extract_id_from_render_data(data)383                except Exception:384                    pass385 386            await context.close()387        finally:388            await browser.close()389            await pw.stop()390 391        return video_id392 393    @classmethod394    def _extract_id_from_render_data(cls, data: dict) -> Optional[str]:395        """从 RENDER_DATA JSON 中递归提取 aweme_id / video_id。"""396        if isinstance(data, dict):397            for key in ('aweme_id', 'video_id', 'item_id', 'modal_id'):398                val = data.get(key)399                if val and isinstance(val, (int, str)) and str(val).isdigit():400                    return str(val)401            for v in data.values():402                result = cls._extract_id_from_render_data(v)403                if result:404                    return result405        elif isinstance(data, list):406            for item in data:407                result = cls._extract_id_from_render_data(item)408                if result:409                    return result410        return None411 412    # 旧接口兼容413    resolve_short_link = _resolve_short_link414 415    # ================================================================416    # 视频信息获取417    # ================================================================418 419    @classmethod420    async def get_video_info(cls, video_id: str) -> Optional[Dict]:421        """422        通过Playwright浏览器获取视频信息。423        利用页面自身JS发起的API请求获取数据(绕过加密验证)。424        """425        pw, browser = await cls._launch_browser()426        try:427            context = await cls._launch_context(pw, browser)428            page = await context.new_page()429 430            video_data = None431 432            async def on_response(resp):433                nonlocal video_data434                url = resp.url435 436                # 拦截 aweme/detail API437                if 'aweme/v1/web/aweme/detail' in url or (438                    'aweme/v2' in url and 'detail' in url439                ):440                    try:441                        body = await resp.json()442                        detail = body.get('aweme_detail', {})443                        if detail and detail.get('aweme_id'):444                            video_data = detail445                    except Exception:446                        pass447 448                # feed API 备选449                if not video_data and (450                    'aweme/v1/web/aweme/post' in url or 'general/feed' in url451                ):452                    try:453                        body = await resp.json()454                        items = body.get('aweme_list', []) or body.get('data', [])455                        for item in items:456                            if isinstance(item, dict) and item.get('aweme_info'):457                                item = item['aweme_info']458                            if item.get('aweme_id') == video_id:459                                video_data = item460                                break461                    except Exception:462                        pass463 464            page.on('response', on_response)465 466            # 直接访问视频页面(跳过首页,减少被拦截概率)467            video_url = f'https://www.douyin.com/video/{video_id}'468            try:469                await page.goto(video_url, wait_until='domcontentloaded', timeout=25000)470            except Exception:471                # 超时也继续,可能API已经在响应中472                pass473            await page.wait_for_timeout(6000)474 475            # RENDER_DATA 兜底476            if not video_data:477                html = await page.content()478                match = RENDER_DATA_PATTERN.search(html)479                if match:480                    try:481                        from urllib.parse import unquote482                        decoded = unquote(match.group(1))483                        data = json.loads(decoded)484                        video_data = cls._extract_from_render_data(data)485                    except Exception:486                        pass487 488            # page.evaluate 最终兜底489            if not video_data:490                try:491                    video_data = await page.evaluate(492                        '''() => {493                            try {494                                const app = document.querySelector('#RENDER_DATA');495                                if (app) {496                                    const data = JSON.parse(497                                        decodeURIComponent(app.textContent)498                                    );499                                    function findVideo(obj, depth) {500                                        if (depth > 10 || !obj || typeof obj !== 'object')501                                            return null;502                                        if (obj.video && obj.video.playAddr) return obj;503                                        if (obj.video && obj.video.play_addr) return obj;504                                        for (const key in obj) {505                                            const r = findVideo(obj[key], depth + 1);506                                            if (r) return r;507                                        }508                                        return null;509                                    }510                                    return findVideo(data, 0);511                                }512                            } catch(e) {}513                            return null;514                        }'''515                    )516                except Exception:517                    pass518 519        except Exception as e:520            print(f"Playwright error: {e}")521        finally:522            await context.close()523            await browser.close()524            await pw.stop()525 526        return video_data527 528    @classmethod529    def _extract_from_render_data(cls, data: dict, depth: int = 0) -> Optional[Dict]:530        """从RENDER_DATA递归提取视频信息"""531        if depth > 10 or not isinstance(data, dict):532            return None533        if 'video' in data and isinstance(data['video'], dict):534            if 'play_addr' in data['video'] or 'playAddr' in data['video']:535                return data536        for key, value in data.items():537            if isinstance(value, dict):538                result = cls._extract_from_render_data(value, depth + 1)539                if result:540                    return result541            elif isinstance(value, list):542                for item in value:543                    if isinstance(item, dict):544                        result = cls._extract_from_render_data(item, depth + 1)545                        if result:546                            return result547        return None548 549    # ================================================================550    # 清晰度解析551    # ================================================================552 553    @classmethod554    def parse_quality_options(cls, video_data: Dict) -> List[Dict]:555        """解析视频清晰度选项"""556        options = []557        video_info = video_data.get('video', {})558 559        bit_rates = video_info.get('bit_rate', [])560        if bit_rates:561            for br in bit_rates:562                gear_name = br.get('gear_name', '')563                bit_rate_val = br.get('bit_rate', 0)564                play_addr = br.get('play_addr', {}) or br.get('playAddr', {})565                url_list = play_addr.get('url_list', []) or play_addr.get('urlList', [])566 567                if not url_list:568                    continue569 570                label = cls._quality_label(gear_name, bit_rate_val)571                options.append({572                    'label': label,573                    'gear_name': gear_name,574                    'bit_rate': bit_rate_val,575                    'url': url_list[0],576                    'url_list': url_list,577                    'width': play_addr.get('width', 0),578                    'height': play_addr.get('height', 0),579                })580 581        if not options:582            play_addr = video_info.get('play_addr', {}) or video_info.get('playAddr', {})583            url_list = play_addr.get('url_list', []) or play_addr.get('urlList', [])584            if url_list:585                options.append({586                    'label': '默认清晰度',587                    'gear_name': 'default',588                    'bit_rate': video_info.get('bit_rate', 0),589                    'url': url_list[0],590                    'url_list': url_list,591                    'width': play_addr.get('width', 0),592                    'height': play_addr.get('height', 0),593                })594 595        # 去重 + 按码率降序596        seen = set()597        unique = []598        for opt in options:599            if opt['gear_name'] not in seen:600                seen.add(opt['gear_name'])601                unique.append(opt)602        unique.sort(key=lambda x: x['bit_rate'], reverse=True)603        return unique604 605    # ================================================================606    # 字幕提取607    # ================================================================608 609    @classmethod610    def extract_captions(cls, video_data: Dict) -> Optional[str]:611        """提取视频字幕/文案"""612        captions = []613        desc = video_data.get('desc', '')614        if desc:615            captions.append(f"【描述】{desc}")616 617        stickers = video_data.get('interaction_stickers') or []618        for sticker in stickers:619            text = sticker.get('text_content', '') or sticker.get('text', '')620            if text:621                captions.append(f"【贴纸】{text}")622 623        text_extra = video_data.get('text_extra') or []624        for extra in text_extra:625            tag = extra.get('hashtag_name', '') or extra.get('tag', '')626            if tag:627                captions.append(f"#话题# {tag}")628 629        music = video_data.get('music') or {}630        if music:631            music_title = music.get('title', '') or music.get('author', '')632            if music_title:633                captions.append(f"【音乐】{music_title}")634 635        author = video_data.get('author') or {}636        if author:637            nickname = author.get('nickname', '')638            if nickname:639                captions.append(f"【作者】{nickname}")640 641        return '\n'.join(captions) if captions else None642 643    # ================================================================644    # 内部工具645    # ================================================================646 647    @classmethod648    def _quality_label(cls, gear_name: str, bit_rate: int) -> str:649        """生成清晰度标签"""650        label_map = {651            '720_1': '720P 标清',652            '720_2': '720P 高清',653            '1080_1': '1080P 标清',654            '1080_2': '1080P 高清',655            '1080_4': '1080P 超清',656            '1080_8': '1080P 极清',657            'adapt_lowest_1': '自适应_最低',658            'adapt_low_1': '自适应_低',659            'adapt_medium_1': '自适应_中',660            'adapt_high_1': '自适应_高',661            'adapt_highest_1': '自适应_最高',662            'default': '默认清晰度',663        }664        if gear_name in label_map:665            return label_map[gear_name]666        if bit_rate > 8_000_000:667            return f"超清 ({bit_rate // 1_000_000}Mbps)"668        if bit_rate > 4_000_000:669            return f"高清 ({bit_rate // 1_000_000}Mbps)"670        if bit_rate > 1_000_000:671            return f"标清 ({bit_rate // 1_000_000}Mbps)"672        return gear_name or "未知清晰度"673 674 675# ================================================================676# 评论抓取器677# ================================================================678 679class CommentFetcher:680    """抖音评论抓取器 — Playwright 页面内 fetch 调用 API(自动带签名)"""681 682    @classmethod683    def fetch_comments_sync(684        cls, video_id: str, max_comments: int = 3000685    ) -> Dict:686        """同步抓取评论 — 在 Playwright 页面上下文中调用 fetch,自动翻页直到达到上限"""687        from playwright.sync_api import sync_playwright688 689        result = {690            'video_id': video_id,691            'hot_comments': [],692            'latest_comments': [],693            'total_fetched': 0,694            'cursor': 0,695            'has_more': False,696        }697 698        with sync_playwright() as pw:699            browser = pw.chromium.launch(700                headless=True,701                args=['--no-sandbox', '--disable-setuid-sandbox'],702            )703            try:704                context = browser.new_context(705                    user_agent=(706                        'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '707                        'AppleWebKit/537.36 (KHTML, like Gecko) '708                        'Chrome/131.0.0.0 Safari/537.36'709                    ),710                    viewport={'width': 1440, 'height': 900},711                    locale='zh-CN',712                )713                page = context.new_page()714 715                # 访问首页建立 session716                page.goto(717                    'https://www.douyin.com/', wait_until='domcontentloaded', timeout=20000718                )719                page.wait_for_timeout(3000)720 721                # 访问视频页面722                page.goto(723                    f'https://www.douyin.com/video/{video_id}',724                    wait_until='domcontentloaded', timeout=20000,725                )726                page.wait_for_timeout(5000)727 728                # 滚动触发页面 JS 环境完全激活729                for _ in range(3):730                    page.evaluate('window.scrollTo(0, document.body.scrollHeight)')731                    page.wait_for_timeout(2000)732 733                all_hot = []734                all_latest = []735                cursor = 0736                has_more = True737 738                while len(all_hot) + len(all_latest) < max_comments:739                    js_code = f'''740                    async () => {{741                        const url = 'https://www.douyin.com/aweme/v1/web/comment/list/'742                            + '?aweme_id={video_id}&cursor={cursor}&count=50&item_type=0';743                        const resp = await fetch(url, {{ credentials: 'include' }});744                        const text = await resp.text();745                        return text;746                    }}747                    '''748                    try:749                        raw_text = page.evaluate(js_code)750                        data = json.loads(raw_text)751                    except Exception as e:752                        print(f"[CommentFetcher] page eval error: {e}", flush=True)753                        break754 755                    raw = data.get('comments', []) or []756                    if not raw:757                        break758 759                    for c in raw:760                        item = cls._normalize_comment(c)761                        if not item['content']:762                            continue763                        if c.get('is_hot') or c.get('is_hot_comment'):764                            all_hot.append(item)765                        else:766                            all_latest.append(item)767 768                    cursor = data.get('cursor', 0)769                    has_more = data.get('has_more', 0) == 1770                    if not has_more:771                        break772 773                context.close()774            finally:775                browser.close()776 777        result['hot_comments'] = all_hot[:max_comments]778        result['latest_comments'] = all_latest[:max_comments]779        result['total_fetched'] = len(all_hot) + len(all_latest)780        if all_latest:781            result['cursor'] = all_latest[-1].get('create_time', 0)782        result['has_more'] = has_more783 784        print(f"[CommentFetcher] total: {result['total_fetched']} comments for {video_id}",785              flush=True)786        return result787 788    @staticmethod789    def _normalize_comment(c: Dict) -> Dict:790        """统一评论字段格式"""791        user = c.get('user', {}) or {}792        return {793            'cid': c.get('cid', ''),794            'nickname': user.get('nickname', '匿名'),795            'avatar': user.get('avatar_thumb', {}).get('url_list', [''])[0] or '',796            'content': (c.get('text', '') or c.get('content', '')).strip(),797            'digg_count': c.get('digg_count', 0) or 0,798            'reply_count': c.get('reply_comment_total', 0) or 0,799            'create_time': c.get('create_time', 0) or 0,800            'is_hot': bool(801                c.get('is_hot') or802                c.get('is_hot_comment') or803                c.get('label_type') or804                (c.get('digg_count', 0) >= 100)805            ),806        }807