diff --git a/spider/task.py b/spider/task.py index aca4881..5160aea 100644 --- a/spider/task.py +++ b/spider/task.py @@ -585,11 +585,148 @@ def _get_latest_post_url(page): return page.url +def _open_latest_profile_story(page): + post_links = page.locator('//div[@aria-posinset="1"]//a[@role="link"]') + count = post_links.count() + if count == 0: + raise OperationFailed("未找到主页最新动态链接") + + preferred_indexes = [2, 1, 0] + for index in preferred_indexes: + if index >= count: + continue + try: + post_links.nth(index).click(timeout=60000) + sleep(3, 5) + page.reload(timeout=180000) + return page.url + except Error as e: + logger.warning(f"打开主页最新动态失败,尝试下一个链接: index={index}, error={e}") + + raise OperationFailed("打开主页最新动态失败") + + def _is_facebook_home(page): current_url = (page.url or '').rstrip('/') return current_url in {'https://www.facebook.com', 'https://facebook.com'} +def _get_share_config(target_url): + if 'permalink.php?story_fbid' in target_url or '/posts/' in target_url or '/permalink/' in target_url: + return { + 'share_button': '//*[@role="dialog"]//div[@data-ad-rendering-role="share_button"]', + 'input_box': '//form[@method="POST" and count(@*) = 1]/div/div/div[2]', + 'share_now_button': '//span[text()="Share now"]', + } + if 'watch/?v' in target_url or '/videos/' in target_url or 'watch?v' in target_url: + return { + 'share_button': '//span[@dir="auto" and text()="Share"]', + 'input_box': '//form[@method="POST" and count(@*) = 1]/div/div/div[2]', + 'share_now_button': '//span[text()="Share now"]', + } + if '/reel/' in target_url: + return { + 'share_button': '//div[@aria-label="Share"]', + 'input_box': '//form[@method="POST" and count(@*) = 1]/div/div/div[2]', + 'share_now_button': '//span[text()="Share now"]', + } + raise OperationFailed(f'不支持的帖子类型: {target_url}') + + +def _click_share_button(page, selector): + locator = page.locator(selector) + count = locator.count() + + for index in range(count): + candidate = locator.nth(index) + try: + if candidate.is_visible(): + candidate.scroll_into_view_if_needed(timeout=30000) + candidate.click(timeout=60000) + return + except Error: + continue + + element = page.query_selector(selector) + if element is None: + raise OperationFailed(f'未找到分享按钮: {selector}') + + page.evaluate( + """(node) => { + const clickable = + node.closest('[role="button"]') || + node.closest('[aria-label]') || + node.parentElement || + node; + clickable.click(); + }""", + element + ) + page.wait_for_timeout(1000) + + +def _fill_share_content(page, selector, content): + if not content: + return + container = page.locator(selector).first + container.wait_for(state='visible', timeout=60000) + + candidates = [ + container.locator('[contenteditable="true"]').first, + container.locator('[role="textbox"]').first, + page.locator(f'{selector}//div[@contenteditable="true"]').first, + page.locator(f'{selector}//div[@role="textbox"]').first, + container, + ] + + last_error = None + for candidate in candidates: + try: + if candidate.count() == 0: + continue + candidate.wait_for(state='visible', timeout=10000) + candidate.click(timeout=30000) + page.keyboard.press('Control+A') + page.keyboard.press('Backspace') + candidate.fill(content, timeout=60000) + return + except Exception as e: + last_error = e + + element = container.element_handle() + if element is None: + raise OperationFailed(f'未找到分享输入框: {selector}') + + page.evaluate( + """({ node, value }) => { + const editable = + node.matches?.('[contenteditable="true"], [role="textbox"]') + ? node + : node.querySelector?.('[contenteditable="true"], [role="textbox"]') || node; + editable.focus(); + if ('value' in editable) { + editable.value = value; + } else { + editable.textContent = value; + } + editable.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value })); + editable.dispatchEvent(new Event('change', { bubbles: true })); + }""", + {"node": element, "value": content} + ) + page.wait_for_timeout(500) + if last_error: + logger.warning(f"分享文案输入回退到 JS 注入: {last_error}") + + +def _submit_share(page, selector): + share_now_button = page.locator(selector).first + share_now_button.wait_for(state='visible', timeout=60000) + share_now_button.click(timeout=60000) + page.wait_for_selector('//span[text()="Posting..."]', state='detached', timeout=180000) + page.wait_for_selector('//span[text()="Shared to your profile"]', timeout=180000) + + def playwright_post(cookies, content, image_key=None, dry_run=False): path = os.path.join(BASE_PATH, 'chrome', '130-0008', 'chrome.exe') with lock: @@ -1322,78 +1459,80 @@ def playwright_share(cookies, target_url, content): with lock: with sync_playwright() as playwright: update_windows_distinguish() + parsed_cookies = parse_cookies(cookies) + max_browser_retries = 3 + last_error = None - username = 'moremore_51WM1' - password = 'TOv5y0nXCZH_JH+5' - country = 'US' + for browser_attempt in range(max_browser_retries): + browser = None + context = None + try: + browser = playwright.chromium.launch( + headless=False, args=['--start-maximized'], executable_path=path, + # proxy={ + # "server": "http://pr.oxylabs.io:7777", # 必填 + # "username": f"customer-{username}-cc-{country}", + # "password": password + # } + ) - browser = playwright.chromium.launch( - headless=False, args=['--start-maximized'], executable_path=path, - # proxy={ - # "server": "http://pr.oxylabs.io:7777", # 必填 - # "username": f"customer-{username}-cc-{country}", - # "password": password - # } - ) + context = browser.new_context(no_viewport=True) + context.add_cookies(parsed_cookies) + page = context.new_page() + page.set_default_timeout(30000) + page.set_default_navigation_timeout(180000) + check_account_status(page, parsed_cookies) - context = browser.new_context(no_viewport=True) - context.add_cookies(parse_cookies(cookies)) - page = context.new_page() - check_account_status(page, parse_cookies(cookies)) - try: - retry_goto(page, target_url) - if 'permalink.php?story_fbid' in target_url or '/posts/' in target_url or "/permalink/" in target_url: - # 文字或图片类型 - share_button = page.query_selector( - '//*[@role="dialog"]//div[@data-ad-rendering-role="share_button"]') - input_box = '//form[@method="POST" and count(@*) = 1]/div/div/div[2]' - share_now_button = '//span[text()="Share now"]' - share_button.scroll_into_view_if_needed() - page.evaluate("(element) => element.click()", share_button) - time.sleep(1) + retry_goto(page, target_url) + share_config = _get_share_config(target_url) + _click_share_button(page, share_config['share_button']) + _fill_share_content(page, share_config['input_box'], content) + _edit_privacy(page) + _submit_share(page, share_config['share_now_button']) - elif 'watch/?v' in target_url or '/videos/' in target_url or 'watch?v' in target_url: - # 视频类型, 视频类型, - share_button = '//span[@dir="auto" and text()="Share"]' - input_box = '//form[@method="POST" and count(@*) = 1]/div/div/div[2]' - share_now_button = '//span[text()="Share now"]' - page.locator(share_button).first.click() - elif '/reel/' in target_url: - # 短视频类型 - share_button = '//div[@aria-label="Share"]' - input_box = '//form[@method="POST" and count(@*) = 1]/div/div/div[2]' - share_now_button = '//span[text()="Share now"]' - page.locator(share_button).click() - else: - raise OperationFailed(f'不支持的帖子类型: {target_url}') - page.locator(input_box).type(content, delay=30) - _edit_privacy(page) - page.click(share_now_button) - time.sleep(1) - page.wait_for_selector('//span[text()="Posting..."]', state='detached') - time.sleep(1) - success_tag = page.wait_for_selector('//span[text()="Shared to your profile"]') - if not success_tag: - raise OperationFailed('转发失败,原因未知') - cookies = {i['name']: i['value'] for i in parse_cookies(cookies)} + uid = {i['name']: i['value'] for i in parsed_cookies}['c_user'] + retry_goto(page, f'https://facebook.com/profile.php?id={uid}') + page.wait_for_load_state() + post_url = _open_latest_profile_story(page) + screenshot_content = _full_screenshot() + key = f'screenshot/{uuid.uuid4()}.png' + put_object(key, screenshot_content) + return {'response_url': post_url, 'screenshot_key': key} + except TimeoutError as e: + last_error = e + logger.warning( + f"转发任务超时,尝试重建浏览器重试: attempt {browser_attempt + 1}/{max_browser_retries}, error={e}" + ) + except Error as e: + last_error = e + if is_page_crash_error(e): + logger.warning( + f"转发任务页面崩溃,尝试重建浏览器重试: attempt {browser_attempt + 1}/{max_browser_retries}, error={e}" + ) + else: + logger.warning( + f"转发任务 Playwright 异常,尝试重试: attempt {browser_attempt + 1}/{max_browser_retries}, error={e}" + ) + finally: + if context is not None: + try: + context.close() + except Exception: + pass + if browser is not None: + try: + browser.close() + except Exception: + pass - uid = cookies['c_user'] - retry_goto(page, f'https://facebook.com/profile.php?id={uid}') - page.wait_for_load_state() - post_index = page.locator('//div[@aria-posinset="1"]//a[@role="link"]').nth(2) - post_index.click() - time.sleep(5) - page.reload() - post_url = page.url - screenshot_content = _full_screenshot() - except Error as e: - raise OperationFailed(f'操作超时,请重试{e}') - context.close() - browser.close() + if browser_attempt < max_browser_retries - 1: + time.sleep(2) - key = f'screenshot/{uuid.uuid4()}.png' - put_object(key, screenshot_content) - return {'response_url': post_url, 'screenshot_key': key} + if isinstance(last_error, TimeoutError): + raise OperationFailed(f'操作超时,请重试: {last_error}') + if isinstance(last_error, Error) and is_page_crash_error(last_error): + raise OperationFailed(f'页面崩溃,请重试: {last_error}') + raise OperationFailed(f'操作失败,请重试: {last_error}') if __name__ == '__main__': diff --git a/test_playwright_share.py b/test_playwright_share.py new file mode 100644 index 0000000..188ff73 --- /dev/null +++ b/test_playwright_share.py @@ -0,0 +1,43 @@ +import json + +from loguru import logger + +import spider.task as task_module + + +# 直接在这里填写测试参数 +COOKIES = {"c_user":"61588267419224","datr":"3D2XaYqWJ1w9rJU6X6e02or_","fr":"0k8G2UtA1NqMJqD0s.AWemansED9s7o5tmbiUwA7gqAoWOk99OEJw8_zCrRks9IgULoSk.Bplz6g..AAA.0.0.Bplz6g.AWfaScT_l9g4id9lBpHtDtHo-T4","xs":"28:izkUxLXyFn_-OA:2:1771519655:-1:-1"} + +TARGET_URL = "https://www.facebook.com/permalink.php?story_fbid=pfbid023QsxMBw26HAdt3LW1Ln7GUugWYbQkzfL9Ws68XUiuaXJvFD3u1iKtVFq7hpypdFtl&id=61580561183111" + +CONTENT = "You’ve earned my admiration with this quality." + + +def _validate_config(): + missing = [key for key, value in COOKIES.items() if not str(value).strip()] + if missing: + raise ValueError(f"cookies 缺少字段: {', '.join(missing)}") + + if not TARGET_URL.strip(): + raise ValueError("TARGET_URL 不能为空") + + if "facebook.com" not in TARGET_URL: + raise ValueError(f"TARGET_URL 不是有效的 Facebook 链接: {TARGET_URL}") + + +def main(): + _validate_config() + + logger.add("./log/test_playwright_share.log", rotation="20 MB") + + result = task_module.playwright_share( + cookies=COOKIES, + target_url=TARGET_URL, + content=CONTENT, + ) + logger.info("转发结果: {}", result) + print(json.dumps(result, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main()