新增 Pinterest 参考模式:独立于 Google Trends 的完整链路(12国种子词池 / LLM搜索词json_schema+防重复+已用词限100 / 并发爬图 / 多模态分析→原创简报 / 生图带爬取图参考图生图 / UI流程选择)
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
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())
|
||||
Reference in New Issue
Block a user