import argparse import asyncio import mimetypes import os import random import socket import urllib.request from urllib.parse import urlparse, quote import aiohttp from aiofiles import open as aioopen from playwright.async_api import Page, async_playwright def get_filename_from_url(url: str, idx: int) -> str: parsed = urlparse(url) name = os.path.basename(parsed.path) if not name: # 有些URL没有文件名 name = f"img_{idx}" # 如果没有扩展名,尝试补上 if not os.path.splitext(name)[1]: ext = mimetypes.guess_extension(parsed.path.split("?")[0]) name += ext if ext else ".jpg" return name def _probe_proxy(url: str) -> bool: """实测该地址是否是一个可用的 HTTP/HTTPS 代理(短超时 HEAD 探测)""" proxy_handler = urllib.request.ProxyHandler({"http": url, "https": url}) opener = urllib.request.build_opener(proxy_handler) try: req = urllib.request.Request( "http://www.gstatic.com/generate_204", method="HEAD" ) opener.open(req, timeout=2) return True except Exception: return False def _read_windows_registry_proxy() -> str | None: """直接读取 Windows 系统代理配置(设置 → 网络 → 代理),免去端口扫描猜测。 来源:注册表 HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings - ProxyEnable == 1 时,ProxyServer 形如 "127.0.0.1:6696" 或 "http=127.0.0.1:8899;https=..."。 非 Windows 平台或读取失败返回 None。 """ try: import winreg # 仅 Windows 可用 except ImportError: return None try: with winreg.OpenKey( winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Internet Settings", ) as key: enabled, _ = winreg.QueryValueEx(key, "ProxyEnable") if not enabled: return None proxy_server, _ = winreg.QueryValueEx(key, "ProxyServer") if not proxy_server: return None # 可能是 "http=127.0.0.1:8899;https=127.0.0.1:8899" 多协议格式,取第一个地址 first = proxy_server.split(";")[0] if "=" in first: first = first.split("=", 1)[1] return ("http://" + first) if not first.startswith("http") else first except Exception: return None def detect_proxy() -> str | None: """自动确定本地代理,无需手动填写。 优先级(确定性强的方式在前,端口扫描兜底在后): 1. 系统/用户环境变量 (HTTP_PROXY / HTTPS_PROXY ...) 2. 直接读 Windows 注册表系统代理 (Internet Settings / ProxyServer) —— 你配的 6696 直接命中 3. urllib 系统代理 (getproxies,已含注册表/环境变量) 4. 端口预检 + 实测本地常见代理端口(Clash / v2rayN / Shadowsocks 等)—— 仅作兜底 探测不到返回 None(调用方将直连)。 """ # 1. 环境变量(系统代理 / CI 设置) for env in ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"): val = os.environ.get(env) if val: return val.rstrip("/") # 2. 直接读 Windows 注册表里的系统代理(你手动在系统设置里填的端口,这里精确拿到) reg_proxy = _read_windows_registry_proxy() if reg_proxy: print(f"使用代理: {reg_proxy}(来自系统设置/注册表)") return reg_proxy # 3. urllib 系统代理兜底(已涵盖注册表/环境变量,跨平台) sys_proxy = get_system_proxy() if sys_proxy: print(f"使用代理: {sys_proxy}(系统代理)") return sys_proxy # 4. 兜底:实测本地常见代理端口(先快速判断端口是否监听,避免无谓阻塞) candidates = [ # Clash / Clash Verge / Clash for Windows "127.0.0.1:7890", "127.0.0.1:7891", "127.0.0.1:7892", "127.0.0.1:7893", "127.0.0.1:7894", "127.0.0.1:7878", "127.0.0.1:9090", # 9090 为 Clash 外部控制(也可能作代理) # v2rayN / v2ray-core "127.0.0.1:10808", "127.0.0.1:10809", "127.0.0.1:10810", "127.0.0.1:10811", "127.0.0.1:10812", "127.0.0.1:10813", "127.0.0.1:10814", "127.0.0.1:10815", # Shadowsocks / SSWindows "127.0.0.1:1080", "127.0.0.1:1081", "127.0.0.1:1082", "127.0.0.1:1087", "127.0.0.1:8388", "127.0.0.1:8389", # Surge (mac/iOS 风格,本地也可能开) "127.0.0.1:6152", "127.0.0.1:6153", # Quantumult / Quantumult X "127.0.0.1:6155", "127.0.0.1:6170", # 系统代理 / HTTP 调试代理 "127.0.0.1:8888", "127.0.0.1:8080", "127.0.0.1:8081", "127.0.0.1:8088", "127.0.0.1:3128", # 传统 squid 代理 # 其他常见:trojan / Brook / Netch / 蓝灯 / 自由门 / Proxifier "127.0.0.1:10801", "127.0.0.1:10802", "127.0.0.1:10803", "127.0.0.1:10806", "127.0.0.1:8118", # Privoxy "127.0.0.1:10819", "127.0.0.1:2080", "127.0.0.1:1080", ] for hp in candidates: host, port = hp.split(":") try: with socket.create_connection((host, int(port)), timeout=0.4): pass except OSError: continue # 端口没开,直接跳过(快速) if _probe_proxy(f"http://{hp}"): return f"http://{hp}" return None def get_system_proxy() -> str | None: """读取操作系统(Windows)设置的代理,作为探测不到本地代理时的兜底。 Playwright 启动的 Chrome 默认会继承系统代理;但 aiohttp 下载不会, 因此需要显式取出并传给下载阶段。 """ proxies = urllib.request.getproxies() val = proxies.get("https") or proxies.get("http") return val.rstrip("/") if val else None def _session_user_data_dir() -> str: """返回项目内持久化的 Chrome 用户数据目录。 该目录会一直保留(已加入 .gitignore),首次手动登录 Pinterest 后, 登录态(cookies 等)自动缓存在这里,后续运行直接复用,不再需要登录。 """ base = os.path.dirname(os.path.abspath(__file__)) ud = os.path.join(base, ".chrome_session", "User Data") os.makedirs(ud, exist_ok=True) return ud async def download_image(session: aiohttp.ClientSession, url: str, idx: int, sem: asyncio.Semaphore, save_dir: str, proxy: str | None): filename = get_filename_from_url(url, idx) filepath = os.path.join(save_dir, filename) async with sem: # 限制并发数 try: async with session.get(url, proxy=proxy) as resp: if resp.status == 200: async with aioopen(filepath, "wb") as f: await f.write(await resp.read()) print(f"✅ 下载成功: {filepath}") else: print(f"❌ 下载失败 {url} 状态码: {resp.status}") except Exception as e: print(f"⚠️ 下载错误 {url}: {e}") async def download_all(imgs_url: set[str], save_dir: str, proxy: str | None): sem = asyncio.Semaphore(10) # 同时最多10个下载任务 async with aiohttp.ClientSession() as session: tasks = [ download_image(session, url, idx, sem, save_dir, proxy) for idx, url in enumerate(imgs_url) ] await asyncio.gather(*tasks) async def human_move(page: Page, target_x: int, target_y: int): """模拟人工鼠标移动: 分步插值 + 随机抖动, 轨迹带弧度""" # 获取当前鼠标位置(自己维护, playwright 不提供查询) cur_x, cur_y = getattr(human_move, "_pos", (random.randint(100, 800), random.randint(100, 500))) steps = random.randint(15, 30) # 随机控制点让轨迹带弧度(近似贝塞尔) ctrl_x = (cur_x + target_x) / 2 + random.randint(-150, 150) ctrl_y = (cur_y + target_y) / 2 + random.randint(-150, 150) for i in range(1, steps + 1): t = i / steps # 二次贝塞尔插值 x = (1 - t) ** 2 * cur_x + 2 * (1 - t) * t * ctrl_x + t ** 2 * target_x + random.uniform(-2, 2) y = (1 - t) ** 2 * cur_y + 2 * (1 - t) * t * ctrl_y + t ** 2 * target_y + random.uniform(-2, 2) await page.mouse.move(x, y) await page.wait_for_timeout(random.randint(5, 20)) # 毫秒级间隔, 模拟手部移动速度 human_move._pos = (target_x, target_y) async def human_scroll(page: Page, distance: int): """模拟人工滚动: 把总距离拆成多次小幅滚轮事件, 逐段发出""" # 先把鼠标移到页面内一个随机位置再滚 await human_move(page, random.randint(200, 1000), random.randint(200, 700)) remaining = distance while remaining > 0: step = min(random.randint(40, 120), remaining) # 一次滚轮约 40~120px await page.mouse.wheel(0, step) remaining -= step await page.wait_for_timeout(random.randint(30, 100)) # 滚轮事件间隔 async def _ensure_logged_in(page: Page, search_url: str) -> bool: """独立登录检查方法:在已跳到搜索页的前提下确认 Pinterest 已登录。 判定策略(避免误判): - 以「未登录标志」为准:页面上一旦出现 "Log in" / "Sign up" 按钮, 才认定未登录;否则默认已登录(不依赖可能不匹配的已登录选择器)。 - Pinterest 是 SPA,goto 后需等待渲染,否则瞬间误判未登录。 - 已登录 → 立即返回 True,走直路。 - 未登录(被弹回登录墙)→ 回退首页提示手动登录一次,登录成功后返回 True。 - 一直未登录(用户关窗口/放弃)→ 返回 False。 """ async def _has_login_wall() -> bool: """检测是否存在未登录标志(Log in / Sign up 按钮)。""" try: if await page.get_by_role("button", name="Log in").count(): return True except Exception: pass try: if await page.get_by_role("button", name="Sign up").count(): return True except Exception: pass # 兜底:URL 被重定向到 /login 也是未登录的强信号 try: if "/login" in page.url: return True except Exception: pass return False # 已经在搜索页了。等待 SPA 渲染,避免刚加载就被误判。 # 给一点时间让导航/按钮渲染出来(最多等 8 秒,出现登录墙或超时即停)。 try: await page.wait_for_load_state("networkidle", timeout=8000) except Exception: pass # 网络一直不 idle 也不要卡死,继续判断 # 已登录:页面上找不到登录墙 → 直接走直路 if not await _has_login_wall(): print("✅ 已检测到登录态,直接开始搜索") return True # 未登录(搜索页被弹回登录墙):回退首页,提示手动登录一次,成功后立即继续 print("⚠️ 当前未登录(搜索页被拦截)。请在弹出的浏览器窗口中手动登录,登录成功后将自动继续……") try: await page.goto("https://www.pinterest.com/", wait_until="domcontentloaded") # 登录成功后登录墙消失(Log in/Sign up 按钮不再存在)即视为登录 await page.wait_for_function( """() => { const btns = [...document.querySelectorAll('button')]; const hasLogin = btns.some(b => /log\\s*in/i.test(b.textContent || '')); const hasSignup = btns.some(b => /sign\\s*up/i.test(b.textContent || '')); return !hasLogin && !hasSignup; }""", timeout=0, # 0 = 一直等到出现为止,不超时 ) print("✅ 登录成功,继续搜索") return True except Exception: # 用户关掉页面 / 主动放弃 print("❌ 未检测到登录(页面已关闭或放弃登录)。请先登录后再运行脚本。") return False async def scrape(keyword: str, count: int, headless: bool = False, proxy: str | None = None, cdp_url: str | None = None) -> set[str]: imgs_url: set[str] = set() async with async_playwright() as p: if cdp_url: # 高级模式:直接连用户已开好的调试版主浏览器(如 open_chrome_debug.bat) browser = await p.chromium.connect_over_cdp(cdp_url) context = browser.contexts[0] if browser.contexts else await browser.new_context() print(f"已接管本机调试 Chrome: {cdp_url}") else: # 默认模式:用项目内持久化的 Chrome 用户数据目录启动一个可见窗口。 # 首次运行请在弹出的窗口里登录 Pinterest;登录态会缓存在 # .chrome_session 目录中,之后运行自动复用,无需再次登录。 # 复用系统已装的 Chrome(channel="chrome"),无需下载浏览器内核。 # 注意:Playwright 设置用户数据目录必须用 launch_persistent_context, # 它返回的是 context(而非 browser),且该 context 已带登录态。 launch_kwargs = { "headless": headless, "channel": "chrome", "user_data_dir": _session_user_data_dir(), "args": [ "--disable-blink-features=AutomationControlled", "--no-first-run", "--no-default-browser-check", ], } if proxy: launch_kwargs["proxy"] = {"server": proxy} context = await p.chromium.launch_persistent_context(**launch_kwargs) browser = context.browser print("已启动本地 Chrome 窗口(可见,登录态缓存在 .chrome_session)") page: Page = await context.new_page() # 先直奔搜索页(有缓存/已登录时一条直路) search_url = f"https://www.pinterest.com/search/pins/?q={quote(keyword)}" await page.goto(search_url, wait_until="domcontentloaded") # 独立登录检查:已登录直接开始;未登录才回退首页等手动登录 logged_in = await _ensure_logged_in(page, search_url) if not logged_in: # 关闭浏览器(持久化目录已保存任何已有状态),中止本次爬取 if cdp_url: await browser.close() else: await context.close() return set() no_new_rounds = 0 while len(imgs_url) < count and no_new_rounds < 10: await page.locator('div[role="listitem"]').first.wait_for(state="attached", timeout=50_000) before = len(imgs_url) imgs = await page.locator('div[role="listitem"]').all() print(f"已加载 {len(imgs)} 个元素, 已收集 {len(imgs_url)} 张图片") for img in imgs: if len(imgs_url) >= count: break el = img.locator("img").first if not await el.count(): continue srcset = await el.get_attribute("srcset") if not srcset: continue candidates = [ (s.split()[0], float(s.split()[1][:-1])) # (url, 倍率) for s in srcset.split(",") ] max_url = max(candidates, key=lambda x: x[1])[0] imgs_url.add(max_url) # 模拟人工: 鼠标平滑移动到随机位置 + 分段连续滚动, # 平时停 1.5~3 秒, 偶尔长停顿(像在看图) await human_scroll(page, random.randint(600, 1400)) if random.random() < 0.2: await page.wait_for_timeout(random.randint(3000, 6000)) else: await page.wait_for_timeout(random.randint(1500, 3000)) no_new_rounds = no_new_rounds + 1 if len(imgs_url) == before else 0 # 关闭浏览器(持久化目录中的登录态已自动保存,下次运行直接复用) if cdp_url: await browser.close() # CDP 接管模式:只断开,不关主浏览器 else: await context.close() # 持久化 context 模式:关闭即保存登录态 return imgs_url async def main(): parser = argparse.ArgumentParser(description="Pinterest 关键词图片爬取工具 (本地 Chrome + 持久化登录缓存)") parser.add_argument("keyword", help="搜索关键词") parser.add_argument("-n", "--count", type=int, default=40, help="爬取图片数量 (默认 40)") parser.add_argument("--proxy", default=None, help="下载/浏览使用的代理 (默认自动探测本地端口; 传空字符串禁用)") parser.add_argument("--cdp", default=None, help="可选:指定 CDP 地址以接管你已用调试模式打开的主浏览器 " "(见 open_chrome_debug.bat)。不填则默认用项目内持久化缓存目录启动 Chrome," "首次登录后自动缓存登录态") parser.add_argument("--headless", action="store_true", help="自动启动 Chrome 时采用无头模式 (默认显示浏览器窗口)") parser.add_argument("-o", "--output", default=None, help="保存目录 (默认 output/img/<关键词>)") args = parser.parse_args() # 代理解析(命令行 > 本地端口探测 > 系统代理 > 直连);空字符串明确禁用 proxy = args.proxy if proxy is None: proxy = detect_proxy() or get_system_proxy() if proxy: print(f"使用代理: {proxy}(本地探测/系统代理)") else: print("未检测到代理, 将直连 (如无法访问 Pinterest 请手动指定 --proxy)") proxy = proxy or None # 空字符串 -> 禁用 save_dir = args.output or os.path.join(os.getcwd(), "output", "img", args.keyword) os.makedirs(save_dir, exist_ok=True) print(f"关键词: {args.keyword} | 数量: {args.count} | 模式: {'接管主浏览器' if args.cdp else '本地窗口(缓存登录态)'} | 代理: {proxy or '无'}") print(f"保存目录: {save_dir}") imgs_url = await scrape(args.keyword, args.count, args.headless, proxy, args.cdp) await download_all(imgs_url, save_dir, proxy) print(f"完成, 共收集 {len(imgs_url)} 张图片") if __name__ == "__main__": asyncio.run(main())