yyjzt_snapshot.py 32 KB

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