3
0

yyjzt_snapshot.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653
  1. """药九九快照模式 — 一个浏览器从头到尾:检查登录 → 搜索 → 监听API → 匹配DOM → 截卡片"""
  2. import json
  3. import random
  4. import time
  5. import requests
  6. from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError
  7. from commons.Logger import get_spider_logger
  8. from commons.conn_mysql import MySQLPoolOn2
  9. from pipelines.drug_pipelines import DrugPipeline
  10. from oss_upload.oss_upload import AliyunOSSUploader
  11. from spiders.yaojiujiu.yyjzt_crawl import (
  12. PLATFORM_ID, PAGE_SIZE, MAX_PAGES, SEARCH_API,
  13. PRODUCT_LINK_TPL, compute_one_price, generate_sign,
  14. )
  15. logger = get_spider_logger("yaojiujiu_snapshot")
  16. import urllib.parse
  17. SEARCH_PAGE_URL = "https://www.yyjzt.com/search?keyword={keyword}"
  18. LOGIN_URL = "https://www.yyjzt.com/login"
  19. HOME_URL = "https://www.yyjzt.com"
  20. # 参考 main.py 的反爬/弹窗处理
  21. USERNAME_SELECTOR = "input[name='username']"
  22. PASSWORD_SELECTOR = "input[name='password']"
  23. LOGIN_BTN_SELECTOR = "button:has-text('登录')"
  24. class YyjztSnapshotSpider:
  25. def __init__(self, task_dict=None):
  26. self.task_dict = task_dict or {}
  27. self.pipeline = DrugPipeline("yaojiujiu_snapshot")
  28. self.db = MySQLPoolOn2()
  29. self.is_success = True
  30. # 任务参数
  31. self.task_id = ""
  32. self.company_id = ""
  33. self.product_name = ""
  34. self.product_specs = ""
  35. self.product_brand = ""
  36. self.keyword = ""
  37. self.collect_task_id = ""
  38. self.collect_round = "1"
  39. self.account_id = ""
  40. self.collect_region_id = ""
  41. self.sampling_cycle = ""
  42. self.sampling_start_time = ""
  43. self.sampling_end_time = ""
  44. # 账号
  45. self._phone = ""
  46. self._password = ""
  47. if task_dict:
  48. self._parse_task()
  49. def _parse_task(self):
  50. self.task_id = self.task_dict.get("id", "")
  51. self.company_id = self.task_dict.get("company_id", "")
  52. self.product_name = self.task_dict.get("product_name", "")
  53. self.product_specs = self.task_dict.get("product_specs", "")
  54. self.product_brand = self.task_dict.get("product_brand", "")
  55. self.keyword = (
  56. self.task_dict.get("product_keyword", "")
  57. or (self.task_dict.get("product_brand", "").strip() + " " + self.product_name.strip() + " " + self.product_specs.strip()).strip()
  58. ).strip()
  59. self.collect_task_id = self.task_dict.get("collect_task_id", "")
  60. self.collect_round = self.task_dict.get("collect_round", "1")
  61. self.account_id = self.task_dict.get("collect_equipment_account_id", "")
  62. self.collect_region_id = self.task_dict.get("collect_region_id", "")
  63. self.sampling_cycle = self.task_dict.get("sampling_cycle", "")
  64. self.sampling_start_time = self.task_dict.get("sampling_start_time", "")
  65. self.sampling_end_time = self.task_dict.get("sampling_end_time", "")
  66. self._phone = self.task_dict.get("account_phone", "")
  67. self._password = self.task_dict.get("account_password", "")
  68. # ============ 浏览器启动 + 登录 ============
  69. def _get_account(self):
  70. sql = """SELECT `phone`, `password`, `cookie_str` FROM `accounts_platform`
  71. WHERE `platform`=%s AND `status`=1
  72. ORDER BY `cookie_timestamp` ASC LIMIT 1"""
  73. rows = self.db.select_data(sql, (PLATFORM_ID,))
  74. return rows[0] if rows else None
  75. def _is_login(self, page):
  76. """参考 main.py is_login()"""
  77. try:
  78. page.goto(HOME_URL, timeout=15000)
  79. page.wait_for_load_state("networkidle")
  80. time.sleep(2)
  81. if "login" in page.url.lower():
  82. return False
  83. if page.locator(":has-text('欢迎登录')").count() > 0:
  84. return False
  85. return True
  86. except Exception:
  87. return False
  88. def _login_operation(self, page, phone, password):
  89. """参考 main.py login_operation()"""
  90. try:
  91. page.goto(LOGIN_URL, timeout=15000)
  92. page.wait_for_load_state("networkidle")
  93. time.sleep(2)
  94. # 切到密码登录 tab
  95. pwd_tab = page.locator("#tab-first")
  96. if pwd_tab.count() > 0:
  97. pwd_tab.click()
  98. page.wait_for_timeout(500)
  99. # 填手机号
  100. page.fill(USERNAME_SELECTOR, "")
  101. for ch in str(phone):
  102. page.locator(USERNAME_SELECTOR).type(ch, delay=random.randint(60, 180))
  103. logger.info("已输入账号")
  104. # 填密码
  105. page.fill(PASSWORD_SELECTOR, "")
  106. for ch in str(password):
  107. page.locator(PASSWORD_SELECTOR).type(ch, delay=random.randint(60, 180))
  108. logger.info("已输入密码")
  109. # 点登录
  110. page.click(LOGIN_BTN_SELECTOR)
  111. logger.info("已点击登录")
  112. time.sleep(3)
  113. try:
  114. page.wait_for_load_state("networkidle", timeout=15000)
  115. except Exception:
  116. pass
  117. # 检查是否登录成功
  118. if "login" in page.url.lower():
  119. err = page.locator(".el-message--error, [class*='error']").first
  120. if err.count() > 0:
  121. logger.error("登录失败: %s", err.inner_text(timeout=3000))
  122. return False
  123. return True
  124. except PlaywrightTimeoutError as e:
  125. logger.error("登录超时: %s", e)
  126. return False
  127. def _kill_popups(self, page):
  128. """参考 main.py: 先 JS 暴力清遮罩,再点按钮,三层防线不崩"""
  129. # 第一层:JS 先暴力移除遮罩 + 滑块验证 + 恢复滚动(main.py kill_masks)
  130. page.evaluate("""
  131. () => {
  132. // 精准删已知遮罩 + 滑块验证
  133. const selectors = [
  134. '.v-modal', '.el-overlay', '.el-overlay-dialog',
  135. '.el-dialog__wrapper', '.el-message-box__wrapper', '.el-loading-mask',
  136. '.safety-slider-box', '[class*="safety"]', '[class*="slider"]',
  137. '[class*="captcha"]', '.el-popup-parent--hidden'
  138. ];
  139. selectors.forEach(s => {
  140. document.querySelectorAll(s).forEach(e => {
  141. try { e.remove(); } catch(_) {}
  142. });
  143. });
  144. // 泛化兜底:近似全屏 + 高 z-index 的层强制隐藏
  145. const all = Array.from(document.querySelectorAll('body *'));
  146. for (const el of all) {
  147. try {
  148. const s = getComputedStyle(el);
  149. const z = parseInt(s.zIndex || '0', 10);
  150. if ((s.position === 'fixed' || s.position === 'absolute')
  151. && z >= 1000 && s.pointerEvents !== 'none') {
  152. const r = el.getBoundingClientRect();
  153. const nearFull = r.width >= innerWidth * 0.7 && r.height >= innerHeight * 0.7;
  154. if (nearFull) {
  155. el.style.pointerEvents = 'none';
  156. el.style.display = 'none';
  157. }
  158. }
  159. } catch(_) {}
  160. }
  161. // 恢复滚动
  162. document.documentElement.style.overflow = 'auto';
  163. document.body.style.overflow = 'auto';
  164. document.body.style.position = 'static';
  165. document.body.style.width = 'auto';
  166. document.body.classList.remove('el-popup-parent--hidden');
  167. }
  168. """)
  169. page.wait_for_timeout(300)
  170. # 第二层:连续点引导/关闭按钮(兼容多步引导),try 包住不崩
  171. for _ in range(6):
  172. try:
  173. btn = page.locator(
  174. "xpath=//button[normalize-space()='下一步' or normalize-space()='完成' "
  175. "or normalize-space()='我知道了' or normalize-space()='关闭']"
  176. ).first
  177. if btn.count() > 0 and btn.is_visible():
  178. btn.click(timeout=1500)
  179. page.wait_for_timeout(300)
  180. continue
  181. except Exception:
  182. pass
  183. try:
  184. close_icon = page.locator(
  185. ".el-dialog__headerbtn, .el-message-box__headerbtn, "
  186. ".close, .icon-close, .el-icon-close"
  187. ).first
  188. if close_icon.count() > 0 and close_icon.is_visible():
  189. close_icon.click(timeout=1200, force=True)
  190. page.wait_for_timeout(300)
  191. continue
  192. except Exception:
  193. pass
  194. break
  195. # ============ API 搜索 ============
  196. def _api_search_all(self):
  197. """API 搜全量,返回 [(item_dict, page), ...]"""
  198. all_items = []
  199. for p in range(1, MAX_PAGES + 1):
  200. body = {"pageIndex": p, "pageSize": PAGE_SIZE, "platformType": 1,
  201. "keyword": self.keyword, "searchNum": 0,
  202. "showSearchTips": 0, "isNeedAggregation": 0}
  203. ts = int(time.time() * 1000)
  204. # 用 browser 当前 cookie 里的 token
  205. headers = {
  206. "Content-Type": "application/json", "Origin": "https://www.yyjzt.com",
  207. "sy_plat": "B2B", "token_platform_client_type": "USER",
  208. "zhcaiToken": self._token, "deviceId": self._phone,
  209. "_s": generate_sign(self._token, body, ts), "_t": str(ts),
  210. }
  211. try:
  212. resp = requests.post(SEARCH_API, json=body, headers=headers, timeout=30)
  213. data = resp.json()
  214. if data.get("success") and data.get("code") == 200:
  215. items = data["data"]["itemList"]
  216. for item in items:
  217. all_items.append((item, p))
  218. total_count = int(data["data"].get("totalCount") or 0)
  219. if total_count > 0 and len(all_items) >= total_count:
  220. break
  221. if len(items) == 0:
  222. break
  223. else:
  224. logger.error("API page %s: %s", p, data.get("msg"))
  225. break
  226. except Exception as e:
  227. logger.error("API page %s: %s", p, e)
  228. break
  229. time.sleep(random.uniform(1, 2))
  230. logger.info("API 共 %s 条", len(all_items))
  231. return all_items
  232. # ============ DOM 匹配 ============
  233. def _extract_dom_cards(self, page):
  234. """逐张卡片滚到可视区,触发懒加载后再取 innerText"""
  235. cards = []
  236. card_els = page.locator(".goods_card")
  237. cnt = card_els.count()
  238. if cnt == 0:
  239. logger.warning("页面无 .goods_card 元素")
  240. return cards
  241. # 先滚回顶部
  242. page.evaluate("window.scrollTo(0, 0)")
  243. page.wait_for_timeout(300)
  244. # 预估每张卡高度,用于判断是否需要继续滚
  245. first_card_height = 0
  246. try:
  247. box = card_els.nth(0).bounding_box()
  248. if box:
  249. first_card_height = box["height"]
  250. except Exception:
  251. pass
  252. # 每屏大约能显示多少张卡(假设卡高约 260px,视口 900px → ~3排 = 随实际布局调整)
  253. cards_per_screen = max(2, int(900 / max(first_card_height, 200))) if first_card_height else 3
  254. for i in range(cnt):
  255. try:
  256. card = card_els.nth(i)
  257. # 滚到这张卡中间位置,确保它进入可视区
  258. card.scroll_into_view_if_needed()
  259. page.wait_for_timeout(300) # 给懒加载时间
  260. all_text = card.inner_text(timeout=2000).strip()
  261. if all_text:
  262. cards.append({"all_text": all_text, "locator": card, "index": i})
  263. else:
  264. # 偶尔 inner_text 第一次拿不到(渲染慢),重试一次
  265. page.wait_for_timeout(500)
  266. all_text = card.inner_text(timeout=2000).strip()
  267. if all_text:
  268. cards.append({"all_text": all_text, "locator": card, "index": i})
  269. else:
  270. logger.warning("card[%s] 文本为空,可能的结构异常", i)
  271. except Exception as e:
  272. logger.warning("card[%s] 提取失败: %s", i, e)
  273. # 每屏结束时额外等一下,防止滚太快
  274. if (i + 1) % cards_per_screen == 0:
  275. page.wait_for_timeout(400)
  276. logger.info("逐卡提取: %s/%s 个卡片有文本", len(cards), cnt)
  277. return cards
  278. @staticmethod
  279. def _price_variants(value):
  280. """同一个价格的不同字符串形态:15.0 → {"15.00","15.0","15"}"""
  281. vs = set()
  282. vs.add(f"{value:.2f}")
  283. vs.add(str(value))
  284. vs.add(f"{value:.2f}".rstrip("0").rstrip("."))
  285. vs.add(f"{value:.1f}".rstrip("0").rstrip("."))
  286. return vs
  287. def _match(self, api_item, dom_cards, used):
  288. api_name = api_item.get("itemStoreName", "")
  289. api_store = api_item.get("storeName", "")
  290. api_specs = api_item.get("specs", "")
  291. api_price = compute_one_price(api_item)
  292. # sortPrice 是页面实际展示价(折后/拼团后),compute_one_price 是计算价,
  293. # DOM 可能显示其中任意一个,两者都纳入匹配池
  294. api_sort_price = float(api_item.get("sortPrice") or 0)
  295. # 价格变体:compute_one_price + sortPrice 的所有字符串形态
  296. price_pool = self._price_variants(api_price)
  297. if api_sort_price and abs(api_sort_price - api_price) > 0.001:
  298. price_pool |= self._price_variants(api_sort_price)
  299. fail_reasons = []
  300. for c in dom_cards:
  301. if c["index"] in used:
  302. continue
  303. txt = c["all_text"]
  304. name_ok = bool(api_name) and (api_name in txt)
  305. store_ok = bool(api_store) and (api_store in txt)
  306. price_ok = any(p in txt for p in price_pool)
  307. # specs 辅助消歧:同名+同店+同价时,用规格区分
  308. specs_ok = (not api_specs) or (api_specs in txt)
  309. if name_ok and store_ok and price_ok and specs_ok:
  310. return c, ""
  311. fail_reasons.append(
  312. f" card[{c['index']}]: name={name_ok} store={store_ok} price={price_ok} specs={specs_ok} "
  313. f"card_text={txt[:80]}"
  314. )
  315. # 没匹配上,返回原因
  316. detail = (
  317. f"API: name={api_name!r} store={api_store!r} price={api_price}"
  318. f" sortPrice={api_sort_price} specs={api_specs!r} pool={price_pool}\n"
  319. + "\n".join(fail_reasons[:5])
  320. )
  321. return None, detail
  322. # ============ 翻页 ============
  323. def _goto_next_page(self, page):
  324. """点击下一页按钮(参考 main.py goto_next_page),成功返回 True"""
  325. candidates = [
  326. ".el-pagination button.btn-next:not(.is-disabled)",
  327. ".el-pagination__next:not(.is-disabled)",
  328. "button:has-text('下一页'):not([disabled])",
  329. "a:has-text('下一页')",
  330. ]
  331. for sel in candidates:
  332. btn = page.locator(sel).first
  333. if btn.count() > 0:
  334. try:
  335. page.evaluate("window.scrollTo(0, 0);")
  336. btn.click(timeout=5000)
  337. page.wait_for_load_state("networkidle")
  338. return True
  339. except Exception:
  340. continue
  341. return False
  342. # ============ 截图+入库 ============
  343. def _process_page(self, page, items, page_no):
  344. self._kill_popups(page)
  345. if page_no == 1:
  346. # 第一页:URL 打开搜索
  347. url = SEARCH_PAGE_URL.format(keyword=urllib.parse.quote(self.keyword))
  348. page.goto(url, timeout=20000)
  349. else:
  350. # 后续页:点击"下一页"按钮(URL 翻页不生效,Vue 前端必须点按钮)
  351. if not self._goto_next_page(page):
  352. logger.warning("第 %s 页翻页失败,停止", page_no)
  353. return False
  354. page.wait_for_load_state("networkidle")
  355. time.sleep(random.uniform(1, 2))
  356. self._kill_popups(page) # 页面加载完再清一次,防止延迟弹窗
  357. # 截个整页图方便排查
  358. page.screenshot(path=f"./snapshots/full_page_{page_no}.png", full_page=True)
  359. dom_cards = self._extract_dom_cards(page)
  360. logger.info("页%s: DOM=%s API=%s", page_no, len(dom_cards), len(items))
  361. import os
  362. os.makedirs("./snapshots", exist_ok=True)
  363. os.makedirs("./snapshots/unmatched", exist_ok=True)
  364. os.makedirs("./snapshots/debug", exist_ok=True)
  365. # 先把所有 DOM 卡片截小图存 debug,方便排查
  366. for c in dom_cards[:5]: # 只截前5个看结构
  367. try:
  368. jpg = c["locator"].screenshot(type="jpeg", quality=60)
  369. fname = f"debug_card_{page_no}_{c['index']}.jpg"
  370. with open(os.path.join("./snapshots/debug", fname), "wb") as f:
  371. f.write(jpg)
  372. except Exception:
  373. pass
  374. used = set()
  375. matched = 0
  376. unmatched_log = []
  377. for item, _ in items:
  378. if self.product_brand and self.product_brand.strip() not in str(item.get("brandName") or ""):
  379. continue
  380. if self.product_name and self.product_name.strip() not in str(item.get("itemStoreName") or ""):
  381. continue
  382. if self.product_specs and self.product_specs.strip() not in str(item.get("specs") or ""):
  383. continue
  384. card, fail_detail = self._match(item, dom_cards, used)
  385. if not card:
  386. unmatched_log.append(fail_detail)
  387. continue
  388. used.add(card["index"])
  389. try:
  390. card["locator"].scroll_into_view_if_needed()
  391. except Exception:
  392. pass
  393. time.sleep(0.3)
  394. # 截图
  395. try:
  396. jpg = card["locator"].screenshot(type="jpeg", quality=80)
  397. except Exception as e:
  398. logger.warning("截图失败 item=%s: %s", item.get("itemStoreId"), e)
  399. continue
  400. # 上传 OSS
  401. fname = f"yjj_{item.get('itemStoreId','?')}_{page_no}_{card['index']}"
  402. try:
  403. snapshot_url = self.uploader.upload_from_bytes(jpg, fname)
  404. except Exception as e:
  405. logger.warning("OSS上传失败: %s, 存本地", e)
  406. local_path = os.path.join("./snapshots", fname + ".jpg")
  407. with open(local_path, "wb") as f:
  408. f.write(jpg)
  409. snapshot_url = local_path
  410. # 入库
  411. product = self._to_product(item, snapshot_url)
  412. try:
  413. self.pipeline.storge_data(product)
  414. matched += 1
  415. except Exception as e:
  416. logger.exception("入库失败: %s", e)
  417. # 同时写文件
  418. self._all_results.append(product)
  419. # 未匹配的截全卡片图存 unmatched,同时写日志
  420. skipped = len(items) - matched
  421. if unmatched_log:
  422. log_path = os.path.join("./snapshots", f"unmatched_page{page_no}.log")
  423. with open(log_path, "w", encoding="utf-8") as lf:
  424. lf.write(f"=== 页{page_no} 未匹配 {skipped} 条 ===\n\n")
  425. lf.write("\n---\n".join(unmatched_log))
  426. logger.info("未匹配日志: %s", log_path)
  427. # 截几个未匹配的 DOM 卡片
  428. for i, c in enumerate(dom_cards):
  429. if c["index"] not in used and i < 10:
  430. try:
  431. jpg = c["locator"].screenshot(type="jpeg", quality=70)
  432. fname = f"unmatched_{page_no}_{c['index']}.jpg"
  433. with open(os.path.join("./snapshots/unmatched", fname), "wb") as f:
  434. f.write(jpg)
  435. except Exception:
  436. pass
  437. if skipped:
  438. logger.info("页%s: 匹配入库 %s 条, 未匹配跳过 %s 条", page_no, matched, skipped)
  439. def _to_product(self, item, snapshot_url=""):
  440. now = time.strftime("%Y-%m-%d %H:%M:%S")
  441. today = time.strftime("%Y-%m-%d")
  442. sid = str(item.get("itemStoreId") or "")
  443. # min_price = gc-l2-price = 页面实际显示价
  444. min_price = compute_one_price(item)
  445. return {
  446. "platform": PLATFORM_ID, "item_id": sid,
  447. "enterprise_id": self.company_id,
  448. "product_name": item.get("itemStoreName") or "",
  449. "spec": item.get("specs") or "",
  450. "one_price": 0, # 不存此字段,填0
  451. "detail_url": PRODUCT_LINK_TPL.format(item_store_id=sid) if sid else "",
  452. "shop_name": item.get("storeName") or "",
  453. "anonymous_store_name": "", # 不存此字段
  454. "shop_url": "", "city_name": "", "city_id": "",
  455. "province_name": "", "province_id": "", "area_info": "",
  456. "factory_name": item.get("manufacturer") or item.get("manufactureName") or "",
  457. "scrape_date": today,
  458. "price": min_price, # → DB min_price = gc-l2-price
  459. "sales": item.get("totalCustCount") or "",
  460. "stock_count": item.get("itemStorage") or item.get("ownStorageNumber") or 0,
  461. "snapshot_url": snapshot_url,
  462. "approval_num": item.get("approvalNo") or "",
  463. "produced_time": "",
  464. "deadline": str(item.get("validateTimeEnd") or ""),
  465. "update_time": now, "insert_time": now,
  466. "number": 1, # 固定为1
  467. "product_brand": item.get("brandName") or self.product_brand,
  468. "collect_task_id": self.collect_task_id,
  469. "search_name": self.product_name,
  470. "company_name": item.get("storeFullName") or "",
  471. "collect_config_info": json.dumps({
  472. "sampling_cycle": self.sampling_cycle,
  473. "sampling_start_time": self.sampling_start_time,
  474. "sampling_end_time": self.sampling_end_time,
  475. }, ensure_ascii=False),
  476. "account_id": self.account_id,
  477. "collect_region_id": self.collect_region_id,
  478. "collect_round": self.collect_round,
  479. "is_sold_out": 1 if item.get("isSoldOut") else 0,
  480. }
  481. # ============ 主流程 ============
  482. def run(self):
  483. self.uploader = AliyunOSSUploader()
  484. self._all_results = []
  485. # 1. 拿账号
  486. acc = self._get_account()
  487. if not acc:
  488. logger.error("无可用账号")
  489. return 0, False
  490. phone = acc["phone"]
  491. password = acc["password"]
  492. # 2. 一个浏览器干到底(headless + Linux 兼容)
  493. with sync_playwright() as p:
  494. browser = p.chromium.launch(
  495. headless=True, channel="chrome",
  496. args=[
  497. "--disable-blink-features=AutomationControlled",
  498. "--no-sandbox",
  499. "--disable-setuid-sandbox",
  500. "--disable-dev-shm-usage",
  501. "--disable-gpu",
  502. ],
  503. )
  504. context = browser.new_context(
  505. locale="zh-CN", timezone_id="Asia/Shanghai",
  506. viewport={"width": 1600, "height": 900},
  507. )
  508. page = context.new_page()
  509. # 3. 加载 DB 缓存的 cookie(类似 main.py load_cookies)
  510. db_cookie_str = acc.get("cookie_str") or ""
  511. if db_cookie_str:
  512. try:
  513. db_cookies = json.loads(db_cookie_str)
  514. cookie_list = [
  515. {"name": k, "value": str(v), "domain": ".yyjzt.com", "path": "/"}
  516. for k, v in db_cookies.items() if v and k not in ("Token",)
  517. ]
  518. if cookie_list:
  519. page.goto(HOME_URL, timeout=10000) # 先访问域名
  520. context.add_cookies(cookie_list)
  521. logger.info("已加载 DB 缓存 %s 个 cookie", len(cookie_list))
  522. except (json.JSONDecodeError, Exception) as e:
  523. logger.warning("加载缓存 cookie 失败: %s", e)
  524. # 4. 检查登录态 → 没登录当场登(同一个浏览器)
  525. if self._is_login(page):
  526. logger.info("Cookie 有效,已登录")
  527. else:
  528. logger.info("未登录,开始登录...")
  529. if not self._login_operation(page, phone, password):
  530. logger.error("登录失败")
  531. browser.close()
  532. return 0, False
  533. logger.info("登录成功")
  534. # 登录后清弹窗
  535. self._kill_popups(page)
  536. # 保存 cookie 到 DB
  537. cookies = context.cookies()
  538. cookie_map = {c["name"]: c["value"] for c in cookies}
  539. token = cookie_map.get("yjj-token", "").replace("ssr_", "")
  540. cookie_map["Token"] = token
  541. now_ts = int(time.time())
  542. self.db.execute(
  543. "UPDATE accounts_platform SET cookie_str=%s, cookie_timestamp=%s WHERE phone=%s AND platform=%s",
  544. (json.dumps(cookie_map, ensure_ascii=False), now_ts, str(phone), PLATFORM_ID),
  545. )
  546. logger.info("已保存 %s 个 cookie 到 DB", len(cookie_map))
  547. # 4. 从 browser cookie 提取 token 给 API 用
  548. cookies = context.cookies()
  549. for c in cookies:
  550. if c["name"] == "yjj-token":
  551. self._token = c["value"].replace("ssr_", "")
  552. break
  553. # 5. API 搜全量数据
  554. all_items = self._api_search_all()
  555. if not all_items:
  556. browser.close()
  557. return 0, False
  558. # 6. 按页码分组,逐页截图
  559. from collections import defaultdict
  560. pages = defaultdict(list)
  561. for item, page_no in all_items:
  562. pages[page_no].append((item, page_no))
  563. for page_no in sorted(pages.keys()):
  564. if self._process_page(page, pages[page_no], page_no) is False:
  565. break
  566. time.sleep(random.uniform(2, 3))
  567. # 写结果文件
  568. # out_path = f"./snapshots/result_{int(time.time())}.json"
  569. # with open(out_path, "w", encoding="utf-8") as f:
  570. # json.dump(self._all_results, f, ensure_ascii=False, indent=2, default=str)
  571. # logger.info("结果文件: %s (%s 条)", out_path, len(self._all_results))
  572. browser.close()
  573. logger.info("快照完成: %s 条", self.pipeline.crawl_count)
  574. return self.pipeline.crawl_count, self.is_success, len(pages)
  575. if __name__ == "__main__":
  576. task = {
  577. "id": 999, "company_id": "1",
  578. "product_name": "蒲地蓝消炎片", "product_specs": "",
  579. "product_brand": "999", "product_keyword": "999 蒲地蓝消炎片",
  580. "current_page": 0, "start_page": 1, "end_page": 0,
  581. "scheduler": None, "_device_id": "test", "_platform": "7",
  582. "collect_task_id": "999", "snapshot_collect_status": 0,
  583. "collect_round": 1,
  584. "account_phone": "", "account_password": "", "account_name": "",
  585. "sampling_cycle": "", "sampling_start_time": "", "sampling_end_time": "",
  586. "collect_equipment_account_id": "", "collect_region_id": "",
  587. }
  588. s = YyjztSnapshotSpider(task)
  589. print(s.run())