917613c5ce
- crawler/:豆果(主)/下厨房/Bing 爬虫 + dishes.jsonl(3056 菜→9167 图映射)+ verify_repair 按 URL 重下 - retrieval/:三路检索(BM25 + 本地 BGE-M3 向量 + RRF 融合),FastAPI + 前端; 写死图片目录(默认 crawler/images,可 IMAGE_DIR 覆盖)、绑 0.0.0.0 局域网访问、 启动自动建索引、服务器 serve 图片 - 图片(1.7G)与向量模型(2.3G)不进 git Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
296 lines
11 KiB
Python
296 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""Bing 图片菜品爬虫(每菜多图版,下厨房限速后切换的备用源)。
|
|
|
|
每道菜去 Bing 图片搜索,取**前 N 张**结果,下载 Bing CDN 缩略图(turl,ts*.mm.bing.net
|
|
极耐爬/统一可靠;原图 murl 也一并记录备用),每菜一行 JSON 到 jsonl,images:[...] 记录
|
|
该菜对应的所有图。与 scrape_xiachufang.py 共用 dishes.jsonl + images/:
|
|
- 续抓只跳过 found:true 的菜(失败的会重试)→ 自动只补下厨房没爬到的 ~2082 道
|
|
- 同样的并行 + 全局自适应限速闸(任一线程被限速→全员冷却,根治退避雪崩)
|
|
|
|
用法:
|
|
python scrape_bing.py --out dishes.jsonl --download --per-dish 3 --workers 8 --input todo.txt
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import html
|
|
import json
|
|
import os
|
|
import random
|
|
import re
|
|
import sys
|
|
import threading
|
|
import time
|
|
import urllib.parse
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from datetime import datetime, timezone
|
|
|
|
import requests
|
|
|
|
for _s in (sys.stdout, sys.stderr):
|
|
try:
|
|
_s.reconfigure(encoding="utf-8", errors="replace")
|
|
except Exception:
|
|
pass
|
|
|
|
SEARCH_URL = "https://cn.bing.com/images/search"
|
|
|
|
UA_POOL = [
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0",
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Edg/123.0.0.0",
|
|
]
|
|
ACCEPT_LANGS = ["zh-CN,zh;q=0.9,en;q=0.8", "zh-CN,zh;q=0.9", "zh-CN,zh;q=0.8,en-US;q=0.5"]
|
|
|
|
|
|
def build_headers() -> dict:
|
|
return {
|
|
"User-Agent": random.choice(UA_POOL),
|
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
|
"Accept-Language": random.choice(ACCEPT_LANGS),
|
|
"Referer": "https://cn.bing.com/images/",
|
|
}
|
|
|
|
|
|
_tls = threading.local()
|
|
|
|
|
|
def session() -> requests.Session:
|
|
s = getattr(_tls, "s", None)
|
|
if s is None:
|
|
s = _tls.s = requests.Session()
|
|
return s
|
|
|
|
|
|
# ---------- 全局限速治理(同 scrape_xiachufang:节流 + 全员冷却闸,根治雪崩) ----------
|
|
_pace_lock = threading.Lock()
|
|
_next_search = 0.0
|
|
_resume_at = 0.0
|
|
_penalty = 0
|
|
SEARCH_SPACING = 0.5 # Bing 耐爬,默认间隔小;可被 --spacing 覆盖
|
|
|
|
|
|
def search_pace() -> None:
|
|
global _next_search
|
|
with _pace_lock:
|
|
now = time.time()
|
|
slot = max(now, _next_search)
|
|
_next_search = slot + SEARCH_SPACING + random.uniform(0, 0.3)
|
|
wait = slot - now
|
|
if wait > 0:
|
|
time.sleep(wait)
|
|
|
|
|
|
def gate_wait() -> None:
|
|
while True:
|
|
with _pace_lock:
|
|
wait = _resume_at - time.time()
|
|
if wait <= 0:
|
|
return
|
|
time.sleep(min(wait, 3) + random.uniform(0, 0.4))
|
|
|
|
|
|
def trip_limit() -> None:
|
|
global _resume_at, _penalty
|
|
with _pace_lock:
|
|
_penalty += 1
|
|
cool = min(45 * (1.5 ** (_penalty - 1)), 600)
|
|
nr = time.time() + cool
|
|
if nr > _resume_at:
|
|
_resume_at = nr
|
|
print(f" [!] 限速#{_penalty} 全局冷却 {cool:.0f}s", file=sys.stderr, flush=True)
|
|
|
|
|
|
def ease_limit() -> None:
|
|
global _penalty
|
|
if _penalty:
|
|
with _pace_lock:
|
|
_penalty = max(0, _penalty - 1)
|
|
|
|
|
|
def fetch_search(dish: str, proxy: str | None, retries: int = 5, timeout: int = 25) -> str | None:
|
|
proxies = {"http": proxy, "https": proxy} if proxy else None
|
|
params = {"q": dish, "first": 1, "count": 35}
|
|
url = SEARCH_URL + "?" + urllib.parse.urlencode(params)
|
|
for attempt in range(retries):
|
|
gate_wait()
|
|
search_pace()
|
|
try:
|
|
r = session().get(url, headers=build_headers(), proxies=proxies, timeout=timeout)
|
|
if r.status_code == 200:
|
|
ease_limit()
|
|
return r.text
|
|
if r.status_code in (403, 429, 503):
|
|
trip_limit()
|
|
else:
|
|
print(f" [!] 状态 {r.status_code}", file=sys.stderr)
|
|
time.sleep(3 * (attempt + 1))
|
|
except requests.RequestException as e:
|
|
print(f" [!] 请求异常 {e}", file=sys.stderr)
|
|
time.sleep(5 + random.uniform(0, 5))
|
|
return None
|
|
|
|
|
|
def parse_images(html_text: str, n: int) -> list[dict]:
|
|
"""从 Bing 结果页抽前 n 张图的 {image_url(=turl), murl, turl, title, host_page}。"""
|
|
out: list[dict] = []
|
|
seen: set[str] = set()
|
|
for m in re.finditer(r'class="iusc"[^>]+?\sm="([^"]+)"', html_text):
|
|
try:
|
|
d = json.loads(html.unescape(m.group(1)))
|
|
except Exception:
|
|
continue
|
|
url = d.get("turl") or d.get("murl")
|
|
if not url or url in seen:
|
|
continue
|
|
seen.add(url)
|
|
out.append({
|
|
"image_url": url,
|
|
"murl": d.get("murl"),
|
|
"turl": d.get("turl"),
|
|
"title": (d.get("t") or "").strip() or None,
|
|
"host_page": d.get("purl"),
|
|
})
|
|
if len(out) >= n:
|
|
break
|
|
return out
|
|
|
|
|
|
def is_image(b: bytes) -> bool:
|
|
return (b[:3] == b"\xff\xd8\xff" or b[:8] == b"\x89PNG\r\n\x1a\n"
|
|
or b[:6] in (b"GIF87a", b"GIF89a")
|
|
or (b[:4] == b"RIFF" and b[8:12] == b"WEBP") or b[:2] == b"BM")
|
|
|
|
|
|
def safe_name(dish: str) -> str:
|
|
return re.sub(r"[^\w一-鿿]+", "_", dish).strip("_") or "dish"
|
|
|
|
|
|
def download(url: str, dish: str, idx: int, img_dir: str,
|
|
proxy: str | None) -> tuple[str | None, int]:
|
|
proxies = {"http": proxy, "https": proxy} if proxy else None
|
|
headers = {"User-Agent": random.choice(UA_POOL), "Referer": "https://cn.bing.com/",
|
|
"Accept": "image/avif,image/webp,image/png,image/*;q=0.8,*/*;q=0.5"}
|
|
try:
|
|
r = session().get(url, headers=headers, proxies=proxies, timeout=20)
|
|
if r.status_code == 200 and len(r.content) >= 800 and is_image(r.content):
|
|
path = os.path.join(img_dir, f"{safe_name(dish)}_{idx:02d}.jpg")
|
|
with open(path, "wb") as f:
|
|
f.write(r.content)
|
|
rel = os.path.relpath(path, os.path.dirname(img_dir) or ".").replace(os.sep, "/")
|
|
return rel, len(r.content)
|
|
except requests.RequestException as e:
|
|
print(f" [!] 下图失败 {e}", file=sys.stderr)
|
|
return None, 0
|
|
|
|
|
|
def scrape_one(dish: str, proxy: str | None, do_download: bool, img_dir: str,
|
|
per_dish: int, jitter: float = 0.0) -> dict:
|
|
if jitter:
|
|
time.sleep(random.uniform(0, jitter))
|
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
base = {"dish": dish, "source": "bing", "fetched_at": now}
|
|
html_text = fetch_search(dish, proxy)
|
|
if html_text is None:
|
|
return {**base, "found": False, "count": 0, "images": [], "reason": "fetch_failed"}
|
|
recs = parse_images(html_text, per_dish)
|
|
if not recs:
|
|
return {**base, "found": False, "count": 0, "images": [], "reason": "no_result"}
|
|
images = []
|
|
for i, rec in enumerate(recs, 1):
|
|
e = dict(rec)
|
|
if do_download:
|
|
rel, size = download(rec["image_url"], dish, i, img_dir, proxy)
|
|
e["image_file"] = rel
|
|
e["bytes"] = size
|
|
if i < len(recs):
|
|
time.sleep(random.uniform(0.05, 0.2))
|
|
images.append(e)
|
|
saved = sum(1 for e in images if not do_download or e.get("image_file"))
|
|
if do_download and saved == 0:
|
|
return {**base, "found": False, "count": 0, "images": images, "reason": "download_failed"}
|
|
return {**base, "found": True, "count": saved, "images": images}
|
|
|
|
|
|
def load_done(out_path: str) -> set[str]:
|
|
done = set()
|
|
if os.path.exists(out_path):
|
|
with open(out_path, encoding="utf-8") as f:
|
|
for line in f:
|
|
try:
|
|
rec = json.loads(line)
|
|
if rec.get("found"): # 只跳过成功的;失败行会重试
|
|
done.add(rec["dish"])
|
|
except Exception:
|
|
pass
|
|
return done
|
|
|
|
|
|
def main():
|
|
global SEARCH_SPACING
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("dishes", nargs="*")
|
|
ap.add_argument("--input", help="菜名文件,一行一道")
|
|
ap.add_argument("--out", default="dishes.jsonl")
|
|
ap.add_argument("--download", action="store_true")
|
|
ap.add_argument("--img-dir", default="images")
|
|
ap.add_argument("--per-dish", type=int, default=3)
|
|
ap.add_argument("--proxy", default=os.environ.get("CRAWL_PROXY"))
|
|
ap.add_argument("--workers", type=int, default=8)
|
|
ap.add_argument("--jitter", type=float, default=1.0)
|
|
ap.add_argument("--spacing", type=float, default=SEARCH_SPACING)
|
|
args = ap.parse_args()
|
|
SEARCH_SPACING = args.spacing
|
|
|
|
dishes = list(args.dishes)
|
|
if args.input:
|
|
with open(args.input, encoding="utf-8") as f:
|
|
dishes += [ln.strip() for ln in f if ln.strip()]
|
|
if not dishes:
|
|
ap.error("没给菜名")
|
|
dishes = list(dict.fromkeys(dishes))
|
|
|
|
os.makedirs(args.img_dir, exist_ok=True)
|
|
done = load_done(args.out)
|
|
todo = [d for d in dishes if d not in done]
|
|
print(f"共 {len(dishes)} 道,已完成 {len(done)},本次待抓 {len(todo)},并行 {args.workers} 线程,源 Bing",
|
|
flush=True)
|
|
if not todo:
|
|
print("没有待抓的菜。")
|
|
return
|
|
|
|
ok = miss = 0
|
|
t0 = time.time()
|
|
lock = threading.Lock()
|
|
with open(args.out, "a", encoding="utf-8") as fout, \
|
|
ThreadPoolExecutor(max_workers=args.workers) as ex:
|
|
futs = {ex.submit(scrape_one, d, args.proxy, args.download,
|
|
args.img_dir, args.per_dish, args.jitter): d for d in todo}
|
|
for i, fut in enumerate(as_completed(futs), 1):
|
|
dish = futs[fut]
|
|
try:
|
|
rec = fut.result()
|
|
except Exception as e:
|
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
rec = {"dish": dish, "source": "bing", "found": False, "count": 0,
|
|
"images": [], "reason": f"error:{type(e).__name__}", "fetched_at": now}
|
|
with lock:
|
|
fout.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
fout.flush()
|
|
if rec["found"]:
|
|
ok += 1
|
|
else:
|
|
miss += 1
|
|
if i % 25 == 0 or i == len(todo):
|
|
rate = i / max(time.time() - t0, 1e-6)
|
|
eta = (len(todo) - i) / rate if rate else 0
|
|
print(f"[{i}/{len(todo)}] ✓{ok} ✗{miss} {rate*60:.0f}道/分 ETA {eta/60:.0f}分",
|
|
flush=True)
|
|
print(f"\n完成:成功 {ok} 缺图 {miss} → {args.out}", flush=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|