169 lines
6.4 KiB
Python
169 lines
6.4 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
批量网页爬取器 (Playwright + stealth + 系统Chrome)
|
|
用法:
|
|
python crawl.py urls.txt [--out 输出目录] [--delay 2,5] [--timeout 60]
|
|
|
|
urls.txt: 每行一个网址, # 开头为注释, 空行忽略
|
|
输出:
|
|
out/0001_<域名>_<时间戳>.html 完整网页HTML
|
|
out/0001_<域名>_<时间戳>.txt 提取的纯文本正文
|
|
out/results.csv 汇总表(序号/网址/标题/状态/文件)
|
|
out/cookies.json 会话cookie(自动复用, 减少重复验证)
|
|
"""
|
|
import argparse, csv, json, os, random, re, sys, time, urllib.parse
|
|
from datetime import datetime
|
|
from playwright.sync_api import sync_playwright
|
|
from playwright_stealth import Stealth
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
COOKIE_FILE = os.path.join(HERE, "cookies.json")
|
|
|
|
def load_urls(path):
|
|
urls = []
|
|
with open(path, encoding="utf-8") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if not line.startswith("http"):
|
|
line = "https://" + line
|
|
urls.append(line)
|
|
return urls
|
|
|
|
def safe_name(url, idx):
|
|
host = urllib.parse.urlparse(url).netloc.replace("www.", "").replace(".", "_")
|
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
return f"{idx:04d}_{host}_{ts}"
|
|
|
|
def is_challenge_page(title, html):
|
|
"""判断是否仍在反爬验证页 (通用启发式, 可按需调整)"""
|
|
low = html.lower()
|
|
if "access denied" in title.lower() or "403" in title:
|
|
return True
|
|
if "bot check" in low or "captcha" in low or "cf-challenge" in low:
|
|
return True
|
|
# 页面过小且标题是站名本身 -> 多半是验证壳
|
|
if len(html) < 8000 and "techpowerup" in low:
|
|
return True
|
|
return False
|
|
|
|
def wait_page_settle(page, timeout_s=60):
|
|
"""等页面稳定: 挑战自动跳转结束 + 内容可读"""
|
|
last_title, stable = "", 0
|
|
start = time.time()
|
|
while time.time() - start < timeout_s:
|
|
time.sleep(1)
|
|
try:
|
|
title = page.title()
|
|
html = page.content()
|
|
except Exception:
|
|
continue # 正在跳转
|
|
if is_challenge_page(title, html):
|
|
stable = 0
|
|
continue
|
|
if title == last_title:
|
|
stable += 1
|
|
if stable >= 2 and len(html) > 1000:
|
|
return True, title, html
|
|
else:
|
|
stable = 0
|
|
last_title = title
|
|
try:
|
|
return True, page.title(), page.content()
|
|
except Exception:
|
|
return False, "", ""
|
|
|
|
def crawl_one(page, url, timeout_s):
|
|
page.goto(url, wait_until="domcontentloaded", timeout=timeout_s * 1000)
|
|
ok, title, html = wait_page_settle(page, timeout_s)
|
|
if not ok:
|
|
raise RuntimeError(f"页面加载超时({timeout_s}s)")
|
|
if is_challenge_page(title, html):
|
|
raise RuntimeError(f"仍被反爬拦截: title={title!r} size={len(html)}")
|
|
text = page.inner_text("body")
|
|
return title, html, text
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("urls_file")
|
|
ap.add_argument("--out", default=os.path.join(HERE, "out"))
|
|
ap.add_argument("--delay", default="2,5", help="每次请求间随机延迟秒数, 如 2,5")
|
|
ap.add_argument("--timeout", type=int, default=60, help="单页最长等待秒数")
|
|
ap.add_argument("--retry", type=int, default=2, help="失败重试次数")
|
|
args = ap.parse_args()
|
|
|
|
urls = load_urls(args.urls_file)
|
|
if not urls:
|
|
print("urls.txt 里没有有效网址"); return
|
|
os.makedirs(args.out, exist_ok=True)
|
|
dmin, dmax = map(float, args.delay.split(","))
|
|
|
|
results = []
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch(
|
|
headless=True,
|
|
executable_path="/usr/bin/google-chrome",
|
|
args=["--disable-blink-features=AutomationControlled",
|
|
"--no-sandbox", "--disable-gpu"],
|
|
)
|
|
ctx = browser.new_context(
|
|
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
"(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
|
viewport={"width": 1920, "height": 1080},
|
|
locale="en-US",
|
|
)
|
|
if os.path.exists(COOKIE_FILE):
|
|
try:
|
|
ctx.add_cookies(json.load(open(COOKIE_FILE)))
|
|
print("[i] 已复用上次会话 cookie")
|
|
except Exception as e:
|
|
print(f"[i] cookie 复用失败(忽略): {e}")
|
|
Stealth().apply_stealth_sync(ctx)
|
|
page = ctx.new_page()
|
|
|
|
for i, url in enumerate(urls, 1):
|
|
print(f"[{i}/{len(urls)}] {url}", flush=True)
|
|
base = safe_name(url, i)
|
|
title, status = "", ""
|
|
for attempt in range(args.retry + 1):
|
|
try:
|
|
title, html, text = crawl_one(page, url, args.timeout)
|
|
status = "OK"
|
|
html_path = os.path.join(args.out, base + ".html")
|
|
txt_path = os.path.join(args.out, base + ".txt")
|
|
with open(html_path, "w", encoding="utf-8") as f: f.write(html)
|
|
with open(txt_path, "w", encoding="utf-8") as f: f.write(text)
|
|
print(f" -> OK {title[:60]!r} html={len(html)//1024}KB txt={len(text)//1024}KB")
|
|
break
|
|
except Exception as e:
|
|
status = f"FAIL: {e}"
|
|
print(f" -> 第{attempt+1}次失败: {e}")
|
|
time.sleep(3)
|
|
results.append({"no": i, "url": url, "title": title, "status": status,
|
|
"file": base + ".html"})
|
|
|
|
# 保存 cookie (验证通过后), 供下次复用
|
|
try:
|
|
json.dump(ctx.cookies(), open(COOKIE_FILE, "w"))
|
|
except Exception:
|
|
pass
|
|
time.sleep(random.uniform(dmin, dmax)) # 礼貌延迟
|
|
|
|
browser.close()
|
|
|
|
# 汇总
|
|
csv_path = os.path.join(args.out, "results.csv")
|
|
with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
|
|
w = csv.writer(f)
|
|
w.writerow(["序号", "网址", "标题", "状态", "文件"])
|
|
for r in results:
|
|
w.writerow([r["no"], r["url"], r["title"], r["status"], r["file"]])
|
|
ok = sum(1 for r in results if r["status"] == "OK")
|
|
print(f"\n完成: {ok}/{len(results)} 成功")
|
|
print(f"HTML/文本: {args.out}/")
|
|
print(f"汇总表: {csv_path}")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|