yjj_v2.py 75 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754
  1. from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError
  2. from logger_config import logger
  3. from datetime import datetime
  4. import random
  5. import csv
  6. import os
  7. import time
  8. import json
  9. import sys
  10. from config import *
  11. from conn_mysql import MySQLPoolOnline
  12. import re
  13. import uuid
  14. import requests
  15. import base64
  16. from io import BytesIO
  17. from PIL import Image
  18. import traceback
  19. import oss2
  20. from scheduler import CrawlerScheduler
  21. # ==================== 调度器配置 ====================
  22. PLATFORM_ID = "7" # 药九九平台编码
  23. # ==================== 从数据库获取可用账号 ====================
  24. def get_account_from_db():
  25. """从 accounts_platform 表获取一个可用的药九九账号(platform=7, status=1)"""
  26. sql = """SELECT `name`, `phone`, `password` FROM `accounts_platform`
  27. WHERE `platform`=7 AND `status`=1
  28. ORDER BY `cookie_timestamp` ASC LIMIT 1"""
  29. rows = mysql_pool.select_data(sql)
  30. if not rows:
  31. logger.error("无可用药九九账号(platform=7, status=1)")
  32. return None
  33. return rows[0]
  34. # ==================== 回告辅助函数 ====================
  35. def _build_report_data(task_dict, **kwargs):
  36. return {
  37. "task_id": task_dict.get("id"),
  38. "platform": task_dict.get("_platform", PLATFORM_ID),
  39. "username": task_dict.get("_device_id", "unknown"),
  40. **kwargs,
  41. }
  42. def _report_page_progress(scheduler, task_dict, page, crawled_count, is_finished=0, total_pages=0):
  43. """逐页回告,返回 True=code=error 需停止"""
  44. if not scheduler:
  45. return False
  46. data = _build_report_data(task_dict,
  47. current_page=page, crawled_count=crawled_count, is_finished=is_finished)
  48. if total_pages:
  49. data["total_pages"] = total_pages
  50. scheduler.post_report(data)
  51. return scheduler.end
  52. def _report_exception(scheduler, task_dict, page, exception_type, remark="", crawled_count=0):
  53. """异常回告"""
  54. if not scheduler:
  55. return
  56. data = _build_report_data(task_dict,
  57. current_page=page, is_finished=0,
  58. exception_type=exception_type, remark=remark)
  59. if page:
  60. data["total_pages"] = page
  61. if crawled_count:
  62. data["crawled_count"] = crawled_count
  63. scheduler.post_report(data)
  64. # from faker import Faker
  65. # 代理IP池
  66. PROXY_POOL_URL = ""
  67. PROXY_VALIDATION_URL = "" # 用于验证代理有效性的URL
  68. PROXY_TIMEOUT = 10 # 代理验证超时时间(秒)
  69. mysql_pool = MySQLPoolOnline()
  70. finished_task_ids = set()
  71. def _chromium_launch_args(chrome_major=None):
  72. """Playwright Chromium 启动参数(反自动化 + 随机 UA 主版本)。"""
  73. major = chrome_major if chrome_major is not None else random.randint(110, 120)
  74. return [
  75. "--disable-blink-features=AutomationControlled",
  76. "--enable-automation=false",
  77. "--disable-infobars",
  78. "--remote-debugging-port=0",
  79. "--start-maximized",
  80. "--disable-extensions",
  81. "--disable-plugins-discovery",
  82. "--no-sandbox",
  83. "--disable-dev-shm-usage",
  84. f"--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
  85. f"(KHTML, like Gecko) Chrome/{major}.0.0.0 Safari/537.36",
  86. ]
  87. def get_random_proxy():
  88. """从代理池获取随机代理IP"""
  89. if not PROXY_POOL_URL.strip():
  90. return None
  91. try:
  92. response = requests.get(PROXY_POOL_URL, timeout=10)
  93. if response.status_code == 200:
  94. proxy = response.text.strip()
  95. if validate_proxy(proxy):
  96. logger.info(f"获取到有效代理: {proxy}")
  97. return proxy
  98. logger.warning(f"代理无效: {proxy}")
  99. except Exception as e:
  100. logger.error(f"获取代理失败: {str(e)}")
  101. return None
  102. def validate_proxy(proxy):
  103. """验证代理IP有效性"""
  104. if not PROXY_VALIDATION_URL.strip():
  105. return False
  106. try:
  107. proxies = {
  108. "http": f"http://{proxy}",
  109. "https": f"https://{proxy}"
  110. }
  111. response = requests.get(
  112. PROXY_VALIDATION_URL,
  113. proxies=proxies,
  114. timeout=PROXY_TIMEOUT
  115. )
  116. return response.status_code == 200
  117. except Exception:
  118. return False
  119. def init_browser_with_proxy(playwright):
  120. proxy = get_random_proxy()
  121. proxy_config = None
  122. if proxy:
  123. proxy_server, proxy_port = proxy.split(":")
  124. proxy_config = {
  125. "server": f"http://{proxy_server}:{proxy_port}",
  126. # "username": "your_proxy_username",
  127. # "password": "your_proxy_password"
  128. }
  129. logger.info(f"使用代理: {proxy_server}:{proxy_port}")
  130. else:
  131. logger.warning("未获取到有效代理,将使用本地IP")
  132. # 启动浏览器(保留原有反爬配置)
  133. return playwright.chromium.launch(
  134. headless=True, # 非无头模式
  135. channel="chrome", # 使用Chrome内核
  136. slow_mo=random.randint(100, 300), # 随机操作延迟
  137. proxy=proxy_config, # 代理配置(None则不使用代理)
  138. args=_chromium_launch_args(),
  139. )
  140. # ==================== 2. 反爬工具函数 ====================
  141. def random_delay(min_seconds, max_seconds):
  142. """生成随机延迟(核心反爬:避免固定间隔)"""
  143. delay = random.uniform(min_seconds, max_seconds)
  144. time.sleep(delay)
  145. return delay
  146. def simulate_human_typing(page, locator, text):
  147. """模拟真人打字(逐个字符输入,带随机间隔)"""
  148. try:
  149. locator.click()
  150. locator.clear()
  151. for char in text:
  152. locator.type(char, delay=random.uniform(MIN_INPUT_DELAY, MAX_INPUT_DELAY))
  153. random_delay(0.05, 0.1) # 字符间额外小延迟
  154. logger.info(f" 模拟真人输入完成:{text}")
  155. except Exception as e:
  156. logger.error(f"模拟打字失败:{e}")
  157. locator.fill(text) # 兜底:直接填充
  158. def save_cookies(context, cookie_path=COOKIE_FILE_PATH):
  159. """保存Cookie到本地JSON文件"""
  160. try:
  161. cookies = context.cookies()
  162. with open(cookie_path, "w", encoding="utf-8") as f:
  163. json.dump(cookies, f, ensure_ascii=False, indent=2)
  164. logger.info(f"Cookie已保存到:{cookie_path}")
  165. return True
  166. except Exception as e:
  167. logger.error(f" 保存Cookie失败:{e}")
  168. return False
  169. def load_cookies(context, cookie_path=COOKIE_FILE_PATH):
  170. """从本地JSON文件加载Cookie到浏览器上下文"""
  171. if not os.path.exists(cookie_path):
  172. logger.warning(f" Cookie文件不存在:{cookie_path}")
  173. return False
  174. try:
  175. with open(cookie_path, "r", encoding="utf-8") as f:
  176. cookies = json.load(f)
  177. context.add_cookies(cookies)
  178. logger.info(f"✅ 已从{cookie_path}加载Cookie")
  179. return True
  180. except Exception as e:
  181. logger.error(f" 加载Cookie失败:{e}")
  182. return False
  183. def is_login(page):
  184. """验证是否已登录(核心:检测登录态)"""
  185. try:
  186. page.goto(LOGIN_VALIDATE_URL, timeout=ELEMENT_TIMEOUT)
  187. page.wait_for_load_state("networkidle")
  188. if "login" in page.url.lower():
  189. logger.warning(" Cookie失效,需要重新登录")
  190. return False
  191. if page.locator(".user-box").count() > 0:
  192. logger.info(" Cookie有效,已保持登录状态")
  193. return True
  194. logger.warning(" 已跳离登录页但未检测到用户元素,判定未登录")
  195. return False
  196. except Exception as e:
  197. logger.error(f" 验证登录状态失败:{e}")
  198. return False
  199. # ==================== 滚动函数重构(核心修改) ====================
  200. def slow_scroll_400px(page,scroll_distance1=400):
  201. """
  202. 慢速滚动400px±50px(模拟真人滑动)
  203. :param page: 页面对象
  204. :return: 滚动是否成功
  205. """
  206. try:
  207. # 生成400±50px的随机滚动距离
  208. scroll_distance = random.randint(
  209. scroll_distance1 - SCROLL_OFFSET_RANGE,
  210. scroll_distance1 + SCROLL_OFFSET_RANGE
  211. )
  212. remaining_distance = scroll_distance
  213. total_steps = int(scroll_distance / SCROLL_STEP)
  214. logger.info(
  215. f"📜 开始慢速滚动(目标距离:{scroll_distance}px,总步数:{total_steps},总时长约{total_steps*SCROLL_INTERVAL:.2f}秒)"
  216. )
  217. # 渐进式滚动(每步50px,间隔0.05秒)
  218. for _ in range(total_steps):
  219. step = min(SCROLL_STEP, remaining_distance)
  220. page.evaluate(f"window.scrollBy(0, {step});")
  221. remaining_distance -= step
  222. time.sleep(SCROLL_INTERVAL)
  223. # 处理剩余不足一步的距离
  224. if remaining_distance > 0:
  225. page.evaluate(f"window.scrollBy(0, {remaining_distance});")
  226. time.sleep(SCROLL_INTERVAL)
  227. # 滚动后等待懒加载完成
  228. page.wait_for_load_state("networkidle", timeout=8000)
  229. random_delay(2.0, 3.0) # 滚动后额外停顿,模拟真人
  230. logger.info(f" 慢速滚动完成,实际滚动距离:{scroll_distance - remaining_distance}px")
  231. return True
  232. except Exception as e:
  233. logger.warning(f" 慢速滚动失败:{e}")
  234. return False
  235. # CSV配置
  236. CSV_FILE_PATH = f"yjj_collect_data_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" # CSV保存路径
  237. CSV_HEADERS = [
  238. "商品标题", "商品采购价格", "商品折扣价格", "规格", "盒数",
  239. "店铺名称", "公司名称",
  240. "有效日期", "生产日期", "批准文号", "采集时间"
  241. ] #表头
  242. # ==================== 登录函数 ====================
  243. def login_operation(page, username, password):
  244. """登录操作函数"""
  245. try:
  246. # 输入手机号(直接用单个变量)
  247. page.wait_for_selector(USERNAME_SELECTOR, timeout=ELEMENT_TIMEOUT, state="visible")
  248. page.wait_for_timeout(timeout=3000)
  249. page.fill(USERNAME_SELECTOR, username)
  250. logger.info(" 已输入登录账号")
  251. # 输入密码
  252. page.wait_for_selector(PASSWORD_SELECTOR, timeout=ELEMENT_TIMEOUT, state="visible")
  253. page.wait_for_timeout(timeout=3000)
  254. page.fill(PASSWORD_SELECTOR, password)
  255. logger.info(" 已输入登录密码")
  256. # 点击登录按钮
  257. page.wait_for_selector(LOGIN_BTN_SELECTOR, timeout=ELEMENT_TIMEOUT)
  258. page.wait_for_timeout(timeout=3000)
  259. page.click(LOGIN_BTN_SELECTOR)
  260. logger.info(" 已点击登录按钮")
  261. page.wait_for_timeout(LOGIN_AFTER_CLICK)
  262. return True
  263. except PlaywrightTimeoutError as e:
  264. logger.error(f" 登录失败:元素定位超时 - {str(e)}")
  265. return False
  266. except Exception as e:
  267. logger.error(f" 登录异常:{str(e)}")
  268. return False
  269. def kill_masks(page):
  270. """
  271. 强制清理残留遮罩层/覆盖层,并恢复 body 可滚动、可点击状态
  272. """
  273. page.evaluate(r"""
  274. () => {
  275. const removed = [];
  276. const hidden = [];
  277. // 1) 先处理已知常见遮罩
  278. const knownSelectors = [
  279. '.v-modal',
  280. '.el-overlay',
  281. '.el-overlay-dialog',
  282. '.el-dialog__wrapper',
  283. '.el-message-box__wrapper',
  284. '.el-loading-mask',
  285. '.el-popup-parent--hidden'
  286. ];
  287. for (const sel of knownSelectors) {
  288. document.querySelectorAll(sel).forEach(el => {
  289. // v-modal / overlay 直接 remove 最省事
  290. removed.push(sel);
  291. el.remove();
  292. });
  293. }
  294. // 2) 再做一次“泛化兜底”:全屏 fixed/absolute + 高 z-index 的覆盖层
  295. // 注意:不要误删页面正常的固定导航,所以加上“近似全屏”的判断
  296. const all = Array.from(document.querySelectorAll('body *'));
  297. for (const el of all) {
  298. const s = window.getComputedStyle(el);
  299. if (!s) continue;
  300. const z = parseInt(s.zIndex || '0', 10);
  301. const pos = s.position;
  302. const pe = s.pointerEvents;
  303. if ((pos === 'fixed' || pos === 'absolute') && z >= 1000 && pe !== 'none') {
  304. const r = el.getBoundingClientRect();
  305. const nearFullScreen =
  306. r.width >= window.innerWidth * 0.8 &&
  307. r.height >= window.innerHeight * 0.8 &&
  308. r.left <= window.innerWidth * 0.1 &&
  309. r.top <= window.innerHeight * 0.1;
  310. // 常见遮罩是半透明背景色,或者透明但拦截点击
  311. const bg = s.backgroundColor || '';
  312. const looksLikeMask =
  313. nearFullScreen && (bg.includes('rgba') || bg.includes('rgb') || s.opacity !== '1');
  314. if (nearFullScreen) {
  315. // 不管透明不透明,只要近似全屏且高 z-index,就先让它不拦截点击
  316. el.style.pointerEvents = 'none';
  317. el.style.display = 'none';
  318. hidden.push(el.tagName + '.' + (el.className || ''));
  319. }
  320. }
  321. }
  322. // 3) 恢复 body / html 的滚动与交互(很多弹窗会锁滚动)
  323. document.documentElement.style.overflow = 'auto';
  324. document.body.style.overflow = 'auto';
  325. document.body.style.position = 'static';
  326. document.body.style.width = 'auto';
  327. document.body.style.paddingRight = '0px';
  328. // 4) 去掉 Element-UI 常见的锁定 class
  329. document.body.classList.remove('el-popup-parent--hidden');
  330. return { removed, hiddenCount: hidden.length, hidden };
  331. }
  332. """)
  333. def force_close_popup(page):
  334. """关闭新手引导/遮罩(多步:下一步/完成/我知道了),并兜底移除遮罩层"""
  335. try:
  336. # 1) 尝试连续点“下一步/完成/我知道了/关闭”
  337. for _ in range(5): # 最多点5次,足够覆盖多步引导
  338. btn = page.locator(
  339. "//button[normalize-space()='下一步' or normalize-space()='完成' or normalize-space()='我知道了' or normalize-space()='关闭']"
  340. ).first
  341. if btn.count() > 0 and btn.is_visible():
  342. btn.click(timeout=1500)
  343. page.wait_for_timeout(300)
  344. continue
  345. # 有些引导是右上角 X(如果存在就点)
  346. close_icon = page.locator(
  347. "xpath=//*[contains(@class,'close') or contains(@class,'el-icon-close') or name()='svg' or name()='i'][1]"
  348. ).first
  349. if close_icon.count() > 0 and close_icon.is_visible():
  350. close_icon.click(timeout=1000)
  351. page.wait_for_timeout(300)
  352. continue
  353. break
  354. # 2) 兜底:移除常见遮罩层(element-ui / 通用 mask/overlay)
  355. page.evaluate("""
  356. const selectors = [
  357. '.v-modal', '.el-overlay', '.el-overlay-dialog', '.el-dialog__wrapper',
  358. '[class*="mask"]', '[class*="overlay"]', '[style*="z-index"]'
  359. ];
  360. for (const sel of selectors) {
  361. document.querySelectorAll(sel).forEach(el => {
  362. const s = window.getComputedStyle(el);
  363. // 只移除“覆盖层”倾向的元素:fixed/absolute 且 z-index 很高
  364. if ((s.position === 'fixed' || s.position === 'absolute') && parseInt(s.zIndex || '0', 10) >= 1000) {
  365. el.remove();
  366. }
  367. });
  368. }
  369. """)
  370. except Exception:
  371. pass
  372. def pick_search_input(page):
  373. """优先选可见且可用的搜索输入框;第一个不行就尝试第二个"""
  374. inputs = page.locator(SEARCH_INPUT_SELECTOR)
  375. cnt = inputs.count()
  376. # 优先检查前两个(你说只有两个)
  377. for i in range(min(cnt, 2)):
  378. candidate = inputs.nth(i)
  379. try:
  380. candidate.wait_for(state="visible", timeout=1500) # 小超时快速试探
  381. if candidate.is_enabled():
  382. return candidate
  383. except PlaywrightTimeoutError:
  384. continue
  385. # 兜底:直接找任意可见的(避免命中 hidden 模板)
  386. candidate = page.locator(f"{SEARCH_INPUT_SELECTOR}:visible").first
  387. candidate.wait_for(state="visible", timeout=ELEMENT_TIMEOUT)
  388. return candidate
  389. def type_slow(locator, text, min_delay=0.06, max_delay=0.18):
  390. """逐字输入,模拟真人打字"""
  391. for ch in text:
  392. locator.type(ch, delay=int(random.uniform(min_delay, max_delay) * 1000))
  393. # ==================== 搜索操作函数 ====================
  394. def search_operation(page, keyword):
  395. """搜索框填充+提交搜索"""
  396. try:
  397. search_url = f"https://www.yyjzt.com/search?keyword={keyword}"
  398. page.goto(search_url)
  399. page.wait_for_timeout(600)
  400. try:
  401. page.wait_for_load_state("networkidle", timeout=10000)
  402. except Exception:
  403. pass
  404. force_close_popup(page)
  405. kill_masks(page)
  406. logger.info("✅ 已触发搜索")
  407. return True
  408. except PlaywrightTimeoutError as e:
  409. logger.error(f" 搜索失败:元素定位超时 - {str(e)}")
  410. return False
  411. except Exception as e:
  412. logger.error(f" 搜索异常:{str(e)}")
  413. return False
  414. #翻下一页
  415. def goto_next_page(page):
  416. """
  417. 尝试翻到下一页;成功返回True,没下一页/翻页失败返回False
  418. 适配常见 ElementUI: .el-pagination .btn-next / .el-pagination__next
  419. """
  420. # 多写几个候选,哪个能用就用哪个
  421. candidates = [
  422. ".el-pagination button.btn-next:not(.is-disabled)",
  423. ".el-pagination__next:not(.is-disabled)",
  424. "button:has-text('下一页'):not([disabled])",
  425. "a:has-text('下一页')",
  426. ]
  427. next_btn = None
  428. for sel in candidates:
  429. loc = page.locator(sel).first
  430. if loc.count() > 0:
  431. next_btn = loc
  432. break
  433. if not next_btn:
  434. return False
  435. # 用“当前页第一个商品标题”做翻页完成的判据(比只等networkidle更稳)
  436. first_title = page.locator(PRODUCT_TITLE_SELECTOR).first
  437. before = ""
  438. try:
  439. if first_title.count() > 0:
  440. before = first_title.inner_text(timeout=2000).strip()
  441. except:
  442. pass
  443. try:
  444. page.evaluate("window.scrollTo(0, 0);")
  445. next_btn.click(timeout=5000)
  446. page.wait_for_load_state("networkidle")
  447. # 等列表发生变化(标题变了 / 或者至少第一个标题重新出现)
  448. if before:
  449. page.wait_for_function(
  450. """(sel, oldText) => {
  451. const el = document.querySelector(sel);
  452. return el && el.innerText && el.innerText.trim() !== oldText;
  453. }""",
  454. arg=(PRODUCT_TITLE_SELECTOR, before),
  455. timeout=5000
  456. )
  457. else:
  458. first_title.wait_for(timeout=1000)
  459. return True
  460. except Exception as e:
  461. logger.warning(f" 翻页失败:{e}")
  462. return False
  463. def popup_guard(page, tag=""):
  464. """
  465. 全局弹窗/遮罩守卫:多步引导 + 关闭按钮 + 遮罩清理 + 恢复滚动
  466. tag 仅用于日志区分调用位置
  467. """
  468. try:
  469. # 给弹窗一点出现时间
  470. page.wait_for_timeout(300)
  471. # 1) 连续点“下一步/完成/我知道了/关闭”
  472. for _ in range(6):
  473. btn = page.locator(
  474. "xpath=//button[normalize-space()='下一步' or normalize-space()='完成' or normalize-space()='我知道了' or normalize-space()='关闭']"
  475. ).first
  476. if btn.count() > 0 and btn.is_visible():
  477. btn.click(timeout=1500)
  478. page.wait_for_timeout(250)
  479. continue
  480. # 2) 常见的 close icon
  481. close_btn = page.locator(
  482. "css=.el-dialog__headerbtn, .el-message-box__headerbtn, .close, .icon-close, .el-icon-close"
  483. ).first
  484. if close_btn.count() > 0 and close_btn.is_visible():
  485. close_btn.click(timeout=1200)
  486. page.wait_for_timeout(250)
  487. continue
  488. break
  489. # 3) 清遮罩 + 恢复滚动/交互
  490. page.evaluate(r"""
  491. () => {
  492. // 第一步:精准清理已知的遮罩/弹窗类名(Element UI框架常用)
  493. const selectors = [
  494. '.v-modal', '.el-overlay', '.el-overlay-dialog', '.el-dialog__wrapper',
  495. '.el-message-box__wrapper', '.el-loading-mask'
  496. ];
  497. selectors.forEach(sel => document.querySelectorAll(sel).forEach(e => e.remove()));
  498. // 泛化兜底:近似全屏 + 高 z-index 的层直接屏蔽
  499. const all = Array.from(document.querySelectorAll('body *'));
  500. for (const el of all) {
  501. const s = getComputedStyle(el); // 获取元素的实际样式(含CSS生效的样式)
  502. const z = parseInt(s.zIndex || '0', 10); // 取元素的层级(z-index),默认0
  503. // 条件1:元素是固定/绝对定位(弹窗/遮罩常见定位方式)+ 层级≥1000(高优先级遮挡)+ 能拦截鼠标事件
  504. if ((s.position === 'fixed' || s.position === 'absolute') && z >= 1000 && s.pointerEvents !== 'none') {
  505. const r = el.getBoundingClientRect(); // 获取元素的尺寸和位置
  506. // 条件2:元素宽度/高度≥屏幕80%(近似全屏遮罩)
  507. const nearFull = r.width >= innerWidth * 0.8 && r.height >= innerHeight * 0.8;
  508. if (nearFull) {
  509. el.style.pointerEvents = 'none'; // 让元素不拦截鼠标点击
  510. el.style.display = 'none'; // 隐藏元素
  511. }
  512. }
  513. }
  514. // 第三步:恢复页面滚动功能(弹窗常把页面设为不可滚动)
  515. document.documentElement.style.overflow = 'auto'; // html标签恢复滚动
  516. document.body.style.overflow = 'auto'; // body标签恢复滚动
  517. document.body.classList.remove('el-popup-parent--hidden'); // 移除Element UI的滚动禁用类
  518. }
  519. """)
  520. logger.info("杀除弹窗成功")
  521. except Exception:
  522. pass
  523. def open_detail_page(list_page, item, keyword, idx, *, timeout=15000):
  524. """
  525. 点击商品进入详情页,兼容:
  526. 1) 新开 tab(返回 detail_page != list_page, opened_new_tab=True)
  527. 2) 同 tab 跳转(detail_page == list_page, opened_new_tab=False)
  528. """
  529. ctx = list_page.context
  530. list_url = list_page.url
  531. detail_page = None
  532. opened_new_tab = False
  533. try:
  534. # 期望新开 tab(很多站点会这样)
  535. with ctx.expect_page(timeout=timeout) as p:
  536. item.click(delay=random.uniform(0.1, 0.3))
  537. detail_page = p.value
  538. opened_new_tab = True
  539. logger.info(f" 「{keyword}」第{idx}个商品 - 新开标签页进入详情")
  540. except PlaywrightTimeoutError:
  541. # 兜底:没新开 tab,大概率是同页跳转/弹层
  542. detail_page = list_page
  543. opened_new_tab = False
  544. logger.info(f" 「{keyword}」第{idx}个商品 - 未新开标签页,按同页进入详情处理")
  545. return detail_page, opened_new_tab, list_url
  546. def return_to_list(list_page, detail_page, opened_new_tab, list_url, keyword, idx):
  547. """
  548. 从详情页返回列表页:
  549. - 新 tab:关闭 tab,然后 bring_to_front 切回
  550. - 同 tab:尽量 go_back 回到 list_url;如果没跳转而是弹层,尝试 ESC
  551. """
  552. # 如果浏览器/页面已经被关了,直接退出,避免二次异常
  553. if list_page is None or list_page.is_closed():
  554. logger.warning(f" 「{keyword}」第{idx}个商品 - 列表页已关闭,无法切回")
  555. return
  556. if opened_new_tab:
  557. # 只关“新开的详情 tab”,绝不关 list_page
  558. try:
  559. if detail_page and (detail_page is not list_page) and (not detail_page.is_closed()):
  560. detail_page.close()
  561. logger.info(f"📌 「{keyword}」第{idx}个商品 - 已关闭详情页标签页")
  562. except Exception as e:
  563. logger.warning(f" 「{keyword}」第{idx}个商品 - 关闭详情页失败:{e}")
  564. # 切回列表页
  565. try:
  566. list_page.bring_to_front()
  567. list_page.mouse.move(random.randint(100, 300), random.randint(200, 400))
  568. random_delay(0.3, 0.8)
  569. list_page.wait_for_load_state("networkidle")
  570. logger.info(f" 「{keyword}」第{idx}个商品 - 已切回列表页(新tab模式)")
  571. except Exception as e:
  572. logger.warning(f" 「{keyword}」第{idx}个商品 - 切回列表页失败:{e}")
  573. return
  574. # 同 tab:detail_page == list_page
  575. try:
  576. # 1) 如果 URL 变了,说明确实跳转了 → go_back 回去
  577. if list_page.url != list_url:
  578. for _ in range(3): # 最多退 3 次,防止死循环
  579. list_page.go_back(timeout=15000)
  580. list_page.wait_for_load_state("domcontentloaded", timeout=15000)
  581. random_delay(0.2, 0.5)
  582. if list_page.url == list_url:
  583. break
  584. logger.info(f" 「{keyword}」第{idx}个商品 - 已返回列表页(同tab跳转模式)")
  585. else:
  586. # 2) URL 没变:可能是弹层详情 → 尝试 ESC 关闭弹层
  587. list_page.keyboard.press("Escape")
  588. random_delay(0.2, 0.5)
  589. logger.info(f" 「{keyword}」第{idx}个商品 - 已尝试关闭弹层并留在列表页(同tab弹层模式)")
  590. list_page.bring_to_front()
  591. list_page.wait_for_load_state("networkidle")
  592. except Exception as e:
  593. logger.warning(f" 「{keyword}」第{idx}个商品 - 同tab返回列表页失败:{e}")
  594. #判断店名是否已经在数据库
  595. def shop_is_exists_database(shop):
  596. query_sql = """
  597. SELECT province, city, business_license_company, qualification_number
  598. FROM retrieve_yjj_shop_info_middle
  599. WHERE shop = %s
  600. """
  601. try:
  602. rows = mysql_pool.select_data(query_sql, (shop,))
  603. result = rows[0] if rows else None
  604. logger.debug("店铺存在校验 | shop=%r | result=%s", shop, result)
  605. is_exists = bool(result)
  606. if is_exists:
  607. logger.info(f"【店铺存在校验】店铺已存在 | 店铺名:{repr(shop)} | 结果:存在(True)不要执行采集店铺")
  608. else:
  609. logger.info(f"【店铺存在校验】店铺不存在 | 店铺名:{repr(shop)} | 结果:不存在(False)")
  610. return is_exists, result
  611. except Exception as e:
  612. logger.error(f"查询店铺失败:{e}")
  613. return False, None
  614. def insert_shop_info_to_db(shop,contact_address, qualification_number, business_license_company, business_license_address, scrape_date, platform, province, city, create_time, update_time):
  615. """
  616. 把字段插入到yjj_shop_info_middle表
  617. :param 各参数: 你要插入的字段值(空字符串也可)
  618. :return: bool - 插入成功返回True,失败返回False
  619. """
  620. sql = """
  621. INSERT INTO retrieve_yjj_shop_info_middle (
  622. shop,
  623. contact_address,
  624. qualification_number,
  625. business_license_company,
  626. business_license_address,
  627. scrape_date,
  628. platform,
  629. province,
  630. city,
  631. create_time,
  632. update_time
  633. ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
  634. ON DUPLICATE KEY UPDATE
  635. contact_address = VALUES(contact_address),
  636. qualification_number = VALUES(qualification_number),
  637. business_license_company = VALUES(business_license_company),
  638. business_license_address = VALUES(business_license_address),
  639. scrape_date = VALUES(scrape_date),
  640. platform = VALUES(platform),
  641. province = VALUES(province),
  642. city = VALUES(city),
  643. update_time = VALUES(update_time)
  644. """
  645. params = (
  646. shop,
  647. contact_address,
  648. qualification_number,
  649. business_license_company,
  650. business_license_address,
  651. scrape_date,
  652. platform,
  653. province,
  654. city,
  655. create_time,
  656. update_time,
  657. )
  658. try:
  659. n = mysql_pool.execute(sql, params)
  660. if n > 0:
  661. logger.info("店铺信息已入库 | shop=%s", shop)
  662. return True
  663. logger.error("店铺信息插入失败:受影响行数为 0 | shop=%s", shop)
  664. return False
  665. except Exception as e:
  666. logger.error("店铺信息插入失败:%s", e)
  667. return False
  668. def insert_single_to_mysql(single_data):
  669. """
  670. 逐条插入单条数据到MySQL数据库
  671. :param single_data: 单条商品数据元组
  672. :return: 插入是否成功
  673. """
  674. insert_sql = """
  675. INSERT INTO retrieve_scrape_data
  676. (enterprise_id,product_name, min_price, manufacture_date,
  677. expiry_date, store_name, company_name, province_name, city_name, manufacturer, product_specs, approval_number, link_url,
  678. scrape_date, is_sold_out, qualification_number, platform_id, number, sales,inventory,snapshot_url,search_name,product_brand,collect_config_info,task_id,insert_time,update_time,collect_equipment_account_id,collect_region_id,collect_round) VALUES (%s, %s, %s, %s,%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,%s, %s, %s);
  679. """
  680. values = (
  681. single_data["company_id"],
  682. single_data["product"],
  683. single_data["min_price"],
  684. single_data["manufacture_date"],
  685. single_data["expiry_date"],
  686. single_data["shop"],
  687. single_data["business_license_company"],
  688. single_data["province"],
  689. single_data["city"],
  690. single_data["manufacturer"],
  691. single_data["specification"],
  692. single_data["approval_number"],
  693. single_data["product_link"],
  694. single_data["scrape_date"],
  695. single_data["is_sold_out"],
  696. single_data["credit_code"],
  697. 7,
  698. single_data["number"],
  699. single_data["sales"],
  700. single_data["inventory"],
  701. single_data["snapshot_url"],
  702. single_data["search_name"],
  703. single_data["product_brand"],
  704. single_data["collect_config_info"],
  705. single_data["task_id"],
  706. single_data["insert_time"],
  707. single_data["update_time"],
  708. single_data["collect_equipment_account_id"],
  709. single_data["collect_region_id"],
  710. single_data["collect_round"],
  711. )
  712. try:
  713. n = mysql_pool.execute(insert_sql, values)
  714. if n > 0:
  715. logger.info("单条采集数据已入库 | product=%s", single_data.get("product", "")[:80])
  716. return True
  717. logger.error("单条数据插入失败:受影响行数为 0")
  718. return False
  719. except Exception as e:
  720. logger.error("单条数据插入失败:%s", e)
  721. return False
  722. def check_dup_in_biz_db(product_link, discount_price_val, scrape_date,keyword_dict):
  723. """直接查询业务表是否存在该商品链接+价格"""
  724. log_context = (
  725. f"【去重校验】商品链接:{product_link.strip()} | 价格:{discount_price_val} "
  726. f"采集日期:{scrape_date.strip()}"
  727. )
  728. collect_round = keyword_dict["collect_round"]
  729. collect_equipment_account_id = keyword_dict["collect_equipment_account_id"]
  730. collect_region_id = keyword_dict["collect_region_id"]
  731. sql = """
  732. SELECT 1 FROM retrieve_scrape_data
  733. WHERE link_url = %s AND min_price = %s AND scrape_date = %s AND platform_id = %s
  734. AND collect_round = %s AND collect_equipment_account_id = %s AND collect_region_id = %s
  735. LIMIT 1
  736. """
  737. params = (
  738. product_link.strip(),
  739. discount_price_val,
  740. scrape_date.strip(),
  741. 7,
  742. collect_round,
  743. collect_equipment_account_id,
  744. collect_region_id,
  745. )
  746. try:
  747. rows = mysql_pool.select_data(sql, params)
  748. is_dup = bool(rows)
  749. if is_dup:
  750. logger.warning(f"{log_context} - 表中已存在重复记录,跳过本次采集")
  751. else:
  752. logger.info(f"{log_context} - 表中无重复记录,正常采集")
  753. return is_dup
  754. except Exception as e:
  755. logger.error(f"查询业务表去重失败:{str(e)}")
  756. return False
  757. # 压缩图片函数
  758. def compress_image(image_data, max_size=4*1024*1024): # 4MB上限
  759. try:
  760. img = Image.open(BytesIO(image_data))
  761. # 将RGBA模式转为RGB(兼容JPEG)
  762. if img.mode in ('RGBA', 'P'): # P是PNG的调色板模式,也需转换
  763. # 新建白色背景的RGB图片,把透明图贴上去(避免透明区域变黑)
  764. bg_img = Image.new('RGB', img.size, (255, 255, 255))
  765. bg_img.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)
  766. img = bg_img
  767. # 缩小分辨率(按比例缩到宽≤1000px)
  768. if img.width > 1000:
  769. ratio = 1000 / img.width
  770. new_size = (int(img.width*ratio), int(img.height*ratio))
  771. img = img.resize(new_size, Image.Resampling.LANCZOS)
  772. output = BytesIO()
  773. img.save(output, format='JPEG', quality=80)
  774. compressed_data = output.getvalue()
  775. if len(compressed_data) > max_size:
  776. output2 = BytesIO()
  777. img.save(output2, format='JPEG', quality=60)
  778. compressed_data = output2.getvalue()
  779. return compressed_data
  780. except Exception as e:
  781. logger.debug(f"图片压缩失败:{e}")
  782. return image_data # 压缩失败返回原始数据
  783. def download_image_to_base64(image_url, save_dir = "./download_images"):
  784. """下载网络图片,返回图片二进制数据(BytesIO)"""
  785. try:
  786. if not os.path.exists(save_dir):
  787. os.makedirs(save_dir) # 创建多级目录(比如a/b/c)
  788. print(f"创建本地保存目录:{save_dir}")
  789. except Exception as e:
  790. print(f"创建保存目录失败:{str(e)}")
  791. return None
  792. try:
  793. # 模拟浏览器请求头,避免被服务器拦截
  794. headers = {
  795. "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
  796. }
  797. response = requests.get(image_url, headers=headers, timeout=15)
  798. response.raise_for_status()
  799. compressed_data = compress_image(response.content)
  800. image_base64 = base64.b64encode(compressed_data).decode("utf-8")
  801. image_data = compressed_data
  802. # 步骤3:提取图片文件名(从URL中截取,避免重复)
  803. # 示例URL:https://xxx.com/123.jpg → 文件名:123.jpg
  804. file_name = image_url.split("/")[-1]
  805. # 处理特殊字符(避免文件名非法)
  806. file_name = file_name.replace("?", "").replace("&", "").replace("=", "")
  807. save_path = os.path.join(save_dir, file_name) # 完整保存路径
  808. # 步骤4:保存图片到本地
  809. with open(save_path, "wb") as f:
  810. f.write(image_data)
  811. print(f"图片已保存到本地:{save_path}")
  812. return image_base64
  813. except requests.exceptions.Timeout:
  814. print(f"下载图片超时:{image_url}")
  815. return None
  816. except requests.exceptions.HTTPError as e:
  817. code = e.response.status_code if e.response is not None else "?"
  818. logger.warning("下载图片 HTTP 错误 | url=%s | status=%s", image_url, code)
  819. return None
  820. except Exception as e:
  821. print(f"下载图片失败:{str(e)}")
  822. return None
  823. def get_ocr_res(img):
  824. try:
  825. #img地址
  826. print(f'开始识别图片:{img}')
  827. request_url = request_url_config
  828. img_base64 = download_image_to_base64(img)
  829. if not img_base64:
  830. print("图片下载/转Base64失败,终止OCR识别")
  831. return None
  832. # 获取access_token
  833. access_token = get_access_token()
  834. if not access_token:
  835. print("获取access_token失败,无法调用OCR接口")
  836. return None
  837. params = {"image": img_base64}
  838. request_url = request_url + "?access_token=" + access_token
  839. headers = {'content-type': 'application/x-www-form-urlencoded'}
  840. response = requests.post(request_url, data=params, headers=headers)
  841. if response:
  842. res = response.json()
  843. # 检查OCR返回是否有错误
  844. if "error_code" in res:
  845. print(f"百度OCR接口错误:{res['error_msg']}(错误码:{res['error_code']})")
  846. return None
  847. # 解析识别结果
  848. new_dic = dict()
  849. for ite in res['words_result'].keys():
  850. new_dic[ite] = res['words_result'][ite]['words']
  851. print('资质数据信息', new_dic)
  852. return new_dic
  853. else:
  854. print("OCR接口返回空响应")
  855. return None
  856. except requests.exceptions.RequestException as e:
  857. print(f"网络错误(图片下载/OCR请求失败):{str(e)}")
  858. return None
  859. except KeyError as e:
  860. print(f"OCR响应格式异常,缺失字段:{str(e)}")
  861. return None
  862. except Exception as e:
  863. print(f"OCR识别未知错误:{str(e)}")
  864. return None
  865. def get_access_token():
  866. AppKey = AppKey_config
  867. AppSrcret = AppSecret_config
  868. token_url =token_url_config
  869. url = f"{token_url}?grant_type=client_credentials&client_id={AppKey}&client_secret={AppSrcret}"
  870. payload = ""
  871. headers = {
  872. 'Content-Type': 'application/json',
  873. 'Accept': 'application/json'
  874. }
  875. try:
  876. response = requests.request("POST", url, headers=headers, data=payload)
  877. response.raise_for_status() # 触发HTTP错误
  878. return response.json()['access_token']
  879. except Exception as e:
  880. print(f"获取access_token失败:{str(e)}")
  881. return None
  882. def extract_province_city(address):
  883. """
  884. 从地址中提取省份和城市
  885. :param address: 营业执照地址(如"福建省福州市马尾区")
  886. :return: (province, city) - 提取到的省份/城市,提取失败返回空字符串
  887. """
  888. if not address: # 地址为空,直接返回空
  889. return "", ""
  890. # 正则1:匹配省份(兼容省/自治区/直辖市/特别行政区)
  891. province_pattern = re.compile(r'([^省]+省|.+自治区|北京市|上海市|天津市|重庆市|.+特别行政区)')
  892. province_match = province_pattern.search(address)
  893. province = province_match.group(1) if province_match else ""
  894. # 正则2:匹配城市(兼容市/自治州/地区/盟,且排除省份已匹配的部分)
  895. # 先去掉已匹配的省份,再匹配城市
  896. address_remain = address.replace(province, "").strip() if province else address.strip()
  897. city_pattern = re.compile(r'([^市]+市|.+自治州|.+地区|.+盟|^[^\d区县镇]+)')
  898. city_match = city_pattern.search(address_remain)
  899. city = city_match.group(1).strip() if city_match else ""
  900. # 兼容直辖市(如"北京市朝阳区"→city=北京市)
  901. if province in ["北京市", "上海市", "天津市", "重庆市"]:
  902. city = province
  903. # 兼容地址不规范的情况(如"福建福州马尾区",无"省"/"市"字)
  904. if not province and not city:
  905. # 匹配前两个地名(如"福建福州"→province=福建,city=福州)
  906. simple_pattern = re.compile(r'^([^\d区县镇]+)')
  907. simple_match = simple_pattern.search(address)
  908. if simple_match:
  909. city = simple_match.group(1).strip() # 只有城市,省份留空
  910. if city and province in city:
  911. city = city.replace(province, "").strip()
  912. return province.strip(), city.strip()
  913. #采集数据核心
  914. def collect_data(page, keyword, keyword_dict, scheduler=None):
  915. """
  916. 1) 先获取当前页商品个数(count)
  917. 2) 按循环次数采集;每循环15次滚动一次 slow_scroll_1200px
  918. 3) 当前页循环完 -> goto_next_page;有下一页继续;无下一页结束该关键词
  919. """
  920. collect_result = []
  921. # seen = set()
  922. # ========== 初始化异常关键词存储 ==========
  923. error_keywords = []
  924. kw = '' # 单个异常关键词变量
  925. logger.info(f"📊 开始采集「{keyword}」的商品数据")
  926. page.wait_for_load_state("networkidle")
  927. #没有找到商品就跳过这个商品
  928. page_no = keyword_dict.get("start_page", 1) # 续传起始页
  929. while True:
  930. logger.info(f"\n📄 「{keyword}」开始采集第 {page_no} 页")
  931. # 记录列表页URL(可用于你后续兜底)
  932. list_page_url = page.url
  933. logger.info(f"📌 已记录商品列表页URL:{list_page_url}")
  934. # ✅ 先获取当前页商品个数
  935. page.wait_for_load_state("networkidle")
  936. total_limit = page.locator(PRODUCT_ITEM_SELECTOR).count()
  937. logger.info(f"📌 「{keyword}」第{page_no}页 初始商品个数(count):{total_limit}")
  938. #获取该商品的总个数
  939. total_goods_nums_elem = page.locator("div.sr-page_turner-pagination-total")
  940. if total_goods_nums_elem.count() > 0:
  941. total_goods_nums = total_goods_nums_elem.inner_text().strip()
  942. logger.info(f"📌 「{keyword} 商品个数(count):{total_goods_nums}")
  943. else:
  944. logger.info(f"📌 「{keyword} 商品个数(count):不超过60个")
  945. # 重置当前页的采集计数
  946. collected_count = 0
  947. # ========= 初始化无匹配计数器(记录标题不包含核心关键词的次数) =========
  948. no_match_count = 0 # 无匹配次数初始化为0
  949. MAX_NO_MATCH = 20 # 最大无匹配次数阈值
  950. for idx in range(total_limit):
  951. detail_page = None
  952. try:
  953. item = page.locator(PRODUCT_ITEM_SELECTOR).nth(idx)
  954. collected_count += 1 # 实际采集计数(用于日志)
  955. # ========= 反爬随机延迟(保留你的原逻辑也行) =========
  956. page.wait_for_load_state("networkidle")
  957. delay = random_delay(MIN_CLICK_DELAY, MAX_CLICK_DELAY)
  958. logger.info(f"📌 「{keyword}」第{page_no}页 第{collected_count}/{total_limit}个商品 - 等待{delay:.2f}秒后采集(反爬)")
  959. # 1. 初始化所有字段默认值
  960. title = "无标题"
  961. price = "0.00"
  962. shop = "无店名"
  963. expiry_date = "无有效期"
  964. manufacture_date = "无生产日期"
  965. approval_number = "无批准文号"
  966. manufacturer = "未知公司"
  967. # discount_price = "0.00"
  968. spec = "未知规格"
  969. num = 1 # ✅ 默认 1
  970. platform = '药九九'
  971. current_time = datetime.now().strftime("%Y-%m-%d")
  972. is_sold_out = 0
  973. # ========= 售罄不跳过 =========
  974. sold_locator = item.locator('div.gc-l1-cirle_tip')
  975. if sold_locator.count() > 0:
  976. is_sold_out = 1
  977. logger.warning(f" 「{keyword}」第{page_no}页 第{collected_count}个商品已售罄")
  978. # 提取商品标题(处理空值)
  979. product_locator = item.locator(PRODUCT_TITLE_SELECTOR)
  980. if product_locator.count() > 0:
  981. title = product_locator.inner_text(timeout=3000).strip()
  982. logger.info(f"{'='*10}「{keyword}」第{collected_count}个商品 - 列表页标题:{title}{'='*10}")
  983. else:
  984. logger.warning(f" 「{keyword}」第{collected_count}个商品 - 列表页标题元素未找到,使用默认值:{title}")
  985. if no_match_count >= MAX_NO_MATCH:
  986. logger.error(f"❌ 关键词「{keyword}」无匹配商品次数已达{MAX_NO_MATCH}次,直接终止当前关键词采集,进入下一个关键词")
  987. _report_page_progress(scheduler, keyword_dict, page_no, len(collect_result),is_finished=1, total_pages=page_no)
  988. return []
  989. # 关键词不在标题中,跳过当前商品
  990. product_brand = keyword_dict.get("product_brand")
  991. if product_brand not in title:
  992. no_match_count += 1
  993. logger.warning(f" 「{keyword}」第{collected_count}个商品 - 标题「{title}」不包含品牌「{product_brand}」(无匹配次数:{no_match_count}/{MAX_NO_MATCH}),跳过本次循环")
  994. continue
  995. product_name = keyword_dict.get("product_name")
  996. if product_name not in title:
  997. no_match_count += 1
  998. logger.warning(f" 「{keyword}」第{collected_count}个商品 - 标题「{title}」不包含核心关键词「{product_name}」(无匹配次数:{no_match_count}/{MAX_NO_MATCH}),跳过本次循环")
  999. continue
  1000. # 提取价格(带缺失日志)
  1001. price_locator = item.locator(PRODUCT_PRICE_SELECTOR).nth(0)
  1002. if price_locator.count() > 0:
  1003. price = price_locator.inner_text(timeout=3000).strip()
  1004. logger.info(f"{'='*10}「{keyword}」第{collected_count}个商品 - 列表页采购价格:{price}{'='*10}")
  1005. else:
  1006. price = "0.00" # 初始化默认值,避免后续报错
  1007. logger.warning(f" 「{keyword}」第{collected_count}个商品「{title}」- 列表页采购价格元素未找到,使用默认值:{price}")
  1008. # 5. 提取公司名称(带缺失日志)
  1009. manufacturer_locator = item.locator(PRODUCT_COMPANY_SELECTOR)
  1010. if manufacturer_locator.count() > 0:
  1011. manufacturer = manufacturer_locator.inner_text(timeout=3000).strip()
  1012. logger.info(f"{'='*10}「{keyword}」第{collected_count}个商品 - 列表页公司名:{manufacturer}{'='*10}")
  1013. else:
  1014. logger.warning(f" 「{keyword}」第{collected_count}个商品「{title}」- 列表页公司名称元素未找到,使用默认值:{manufacturer}")
  1015. #提取店铺名称
  1016. shop_locator = item.locator(PRODUCT_STORE_SELECTOR)
  1017. if shop_locator.count() > 0:
  1018. shop = shop_locator.inner_text(timeout=3000).strip()
  1019. logger.info(f"{'='*10}「{keyword}」第{collected_count}个商品 - 列表页店名:{shop}{'='*10}")
  1020. else:
  1021. logger.warning(f" 「{keyword}」第{collected_count}个商品「{title}」- 列表页店铺名称元素未找到,使用默认值:{shop}")
  1022. #提取折扣价
  1023. discount_price_val_origin = ""
  1024. discount_price = ""
  1025. discount_price_locator = item.locator('span.gc-l2-discount_price').first
  1026. if discount_price_locator.count() > 0:
  1027. discount_price = discount_price_locator.inner_text(timeout=3000).strip()
  1028. discount_price_val_origin = discount_price
  1029. match = re.search(r'\d+\.?\d*', str(discount_price_val_origin))
  1030. discount_price_val = float(match.group()) if match else 0.00
  1031. logger.info(f"{'='*10}「{keyword}」第{collected_count}个商品 - 详情页折扣价:{discount_price_val}{'='*10}")
  1032. else:
  1033. #如果没有拿原价替换
  1034. price = float(price.replace("¥", "").replace(",", "")) if price.replace("¥", "").replace(",", "").replace(".", "") else "0.00"
  1035. discount_price_val = float(price)
  1036. logger.warning(f" 「{keyword}」第{collected_count}个商品「{title}」- 折扣价元素未找到,使用采购价兜底:{discount_price_val}")
  1037. merged_price = f"{price}{discount_price_val_origin}" if discount_price_val_origin else price
  1038. # ========= 模拟点击商品进入详情页 =========
  1039. logger.info(
  1040. f"📌 「{keyword}」第{page_no}页 第{collected_count}个商品「{title}」- 模拟鼠标移动并点击"
  1041. )
  1042. # 点击商品项容器,触发详情展示
  1043. # ========== 点击商品跳详情页 ==========
  1044. # 反爬:模拟真人鼠标移动到商品上再点击(不是直接点击)
  1045. item.hover() # 先悬停
  1046. random_delay(0.2, 0.5) # 悬停后延迟
  1047. item.dispatch_event("mousedown")
  1048. random_delay(0.05, 0.15) # 鼠标按下后延迟
  1049. item.dispatch_event("mouseup")
  1050. random_delay(0.05, 0.1) # 鼠标松开后延迟
  1051. try:
  1052. with page.context.expect_page(timeout=60000) as p:
  1053. item.click(delay=random.uniform(0.1, 0.3))
  1054. detail_page = p.value
  1055. except PlaywrightTimeoutError:
  1056. logger.warning(
  1057. f" 「{keyword}」第{page_no}页 第{collected_count}个商品「{title}」- 未检测到新标签页,使用当前页采集详情"
  1058. )
  1059. detail_page = None # 标记为无新标签页,避免关闭列表页
  1060. # 等待详情加载(优先用新标签页,无则用列表页)
  1061. target_page = detail_page if detail_page else page
  1062. target_page.wait_for_load_state("networkidle", timeout=20000)
  1063. delay = random_delay(MIN_PAGE_DELAY, MAX_PAGE_DELAY)
  1064. logger.info(
  1065. f"📌 「{keyword}」第{page_no}页 第{collected_count}个商品「{title}」- 详情页加载完成,等待{delay:.2f}秒(反爬)"
  1066. )
  1067. # 反爬:检测详情页反爬验证
  1068. # check_anti_crawl(page)
  1069. # ========== 采集详情页的专属信息(有效期/生产日期/批准文号) ==========
  1070. #获取商品详情页链接
  1071. product_link = target_page.url
  1072. logger.info(f"{'='*10}「{keyword}」第{collected_count}个商品 - 详情页链接:{product_link}{'='*10}")
  1073. #如果有需要,还可能要加兜底。
  1074. # ========= ✅ 去重逻辑,拿商品链接和折扣价和有效期和采集日期 =========
  1075. if check_dup_in_biz_db(product_link, discount_price_val, current_time,keyword_dict):
  1076. logger.warning(f" 「{keyword}」第{page_no}页 第{collected_count}个商品(重复):{title},跳过")
  1077. # ========== 关闭新标签页,切回列表页 ==========
  1078. if detail_page and not detail_page.is_closed():
  1079. detail_page.close() # 关闭详情页标签
  1080. logger.info(f"📌 「{keyword}」第{collected_count}个商品 - 已关闭详情页标签页")
  1081. # 切回原列表页(第一个标签页)
  1082. page.bring_to_front() # 激活列表页
  1083. page.mouse.move(random.randint(100, 300), random.randint(200, 400)) # 随机移动鼠标
  1084. random_delay(0.5, 1.0) # 增加切换后延迟
  1085. page.wait_for_load_state("networkidle")
  1086. random_delay(MIN_CLICK_DELAY, MAX_CLICK_DELAY)
  1087. logger.info(f" 「{keyword}」第{collected_count}个商品「{title}」- 已切回列表页")
  1088. if collected_count % 5 == 0 and collected_count > 0:
  1089. logger.info("采满5个往下滑")
  1090. slow_scroll_400px(page)
  1091. page.wait_for_load_state("networkidle")
  1092. continue
  1093. # 提取有效期(处理空值)
  1094. expiry_date_locator = target_page.locator("//span[contains(text(), '有效期')]/following-sibling::span[contains(@class, 'gdb-desc-value4')]")
  1095. if expiry_date_locator.count() > 0:
  1096. expiry_date = expiry_date_locator.inner_text(timeout=3000).strip().replace('-', '') #.replace('近效期','')
  1097. logger.info(f"{'='*10}「{keyword}」第{collected_count}个商品 - 详情页有效期:{expiry_date}{'='*10}")
  1098. else:
  1099. # 修复:替换未定义的i为collected_count
  1100. logger.warning(f" 「{keyword}」第{collected_count}个商品「{title}」- 有效期元素未找到,使用默认值:{expiry_date}")
  1101. # 提取生产日期(修复完成)
  1102. manufacture_date_locator = target_page.locator("//span[@class='gdb-desc-label' and text()='生产日期']/following-sibling::span[1]")
  1103. if manufacture_date_locator.count() > 0:
  1104. manufacture_date = manufacture_date_locator.inner_text(timeout=3000).strip().replace('-', "")
  1105. logger.info(f"{'='*10}「{keyword}」第{collected_count}个商品 - 详情页生产日期:{manufacture_date}{'='*10}")
  1106. else:
  1107. # 修复:替换未定义的i为collected_count
  1108. logger.warning(f" 「{keyword}」第{collected_count}个商品「{title}」- 生产日期元素未找到,使用默认值:{manufacture_date}")
  1109. # 提取批准文号
  1110. approval_number_locator = target_page.locator("//span[contains(text(), '国药准字')]").first
  1111. if approval_number_locator.count() > 0:
  1112. approval_number = approval_number_locator.inner_text(timeout=3000).strip()
  1113. logger.info(f"{'='*10}「{keyword}」第{collected_count}个商品 - 详情页批准文号:{approval_number}{'='*10}")
  1114. else:
  1115. # 修复:替换未定义的i为collected_count
  1116. logger.warning(f" 「{keyword}」第{collected_count}个商品「{title}」- 批准文号元素未找到,使用默认值:{approval_number}")
  1117. #提取规格
  1118. spec_locator = target_page.locator('span.gddd-params_text_line_1[title]')
  1119. if spec_locator.count() > 0:
  1120. spec = spec_locator.nth(2).inner_text(timeout=3000).strip()
  1121. logger.info(f"{'='*10}「{keyword}」第{collected_count}个商品 - 详情页规格:{spec}{'='*10}")
  1122. else:
  1123. # 修复:替换未定义的i为collected_count,补充规格数量不足的提示
  1124. logger.warning(f" 「{keyword}」第{collected_count}个商品「{title}」- 规格元素数量不足,使用默认值:{spec}")
  1125. #提取库存
  1126. storage = ''
  1127. storage_locator = target_page.locator("span.gdb-desc-value7")
  1128. if storage_locator.count() > 0:
  1129. storage = storage_locator.inner_text(timeout=3000).strip()
  1130. logger.info(f"{'='*10}「{keyword}」第{collected_count}个商品 - 详情页库存:{storage}{'='*10}")
  1131. else:
  1132. logger.warning(f" 「{keyword}」第{collected_count}个商品「{title}」- 库存元素数量不足,使用默认值:{storage}")
  1133. #提取销量
  1134. sell = ''
  1135. sell_locator = target_page.locator('.has-join-group span.packUnit-class')
  1136. if sell_locator.count() > 0:
  1137. sell = sell_locator.inner_text(timeout=3000).strip()
  1138. logger.info(f"{'='*10}「{keyword}」第{collected_count}个商品 - 详情页销量:{sell}{'='*10}")
  1139. else:
  1140. logger.warning(f" 「{keyword}」第{collected_count}个商品「{title}」- 没有销量元素,使用默认值:{sell}")
  1141. oss_url = ""
  1142. try:
  1143. local_path, oss_url = screenshot_target_page_to_local_then_oss(
  1144. target_page=target_page,
  1145. full_page=True # 截取全屏
  1146. )
  1147. logger.info("详情页快照已上传 | local=%s | oss=%s", local_path, oss_url)
  1148. except Exception as e:
  1149. logger.warning("详情页快照上传失败:%s", e)
  1150. shop_exists, shop_info = shop_is_exists_database(shop)
  1151. #店铺名不是药品预约中心且店铺名不在数据库就要点击
  1152. if shop != "药品预约中心" and not shop_exists :
  1153. logger.info("店铺名不是药品预约中心且数据库没有该公司的营业执照")
  1154. # 获取营业执照图片 li[data-v-4f79abe8].nth(2)
  1155. # 进入店铺
  1156. random_delay(MIN_CLICK_DELAY, MAX_CLICK_DELAY)
  1157. entershop_btn = target_page.locator('[data-v-c5790f48].btn-text')
  1158. # 增强:先等待进入店铺按钮可见
  1159. entershop_btn.wait_for(state="visible", timeout=10000)
  1160. entershop_btn.scroll_into_view_if_needed() # 确保按钮在视口内
  1161. entershop_btn.hover() # 先悬停
  1162. random_delay(0.2, 0.5) # 悬停后延迟
  1163. entershop_btn.click()
  1164. # entershop_btn.dispatch_event("mousedown")
  1165. random_delay(0.05, 0.15) # 鼠标按下后延迟
  1166. # entershop_btn.dispatch_event("mouseup")
  1167. random_delay(0.05, 0.1) # 鼠标松开后延迟
  1168. target_page.wait_for_load_state("domcontentloaded") # 等DOM加载(比networkidle更适合页面内切换)
  1169. #点击店铺资质
  1170. random_delay(MIN_CLICK_DELAY, MAX_CLICK_DELAY)
  1171. shop_license_page = target_page.locator('li:has-text("店铺资质")')
  1172. shop_license_page.wait_for(state="visible", timeout=10000) # 等待元素加载完成
  1173. shop_license_page.hover() # 先悬停
  1174. random_delay(0.2, 0.5) # 悬停后延迟
  1175. # shop_license_page.dispatch_event("mousedown")
  1176. shop_license_page.click()
  1177. random_delay(0.05, 0.15) # 鼠标按下后延迟
  1178. # shop_license_page.dispatch_event("mouseup")
  1179. random_delay(0.05, 0.1) # 鼠标松开后延迟
  1180. target_page.wait_for_load_state("networkidle")
  1181. slow_scroll_400px(target_page, scroll_distance1=700)
  1182. #获取营业执照图片
  1183. target_page.wait_for_load_state("load")
  1184. ocr_res = None
  1185. shop_license_div = target_page.locator('div.shop-licensesImg').nth(0)
  1186. shop_license_div.wait_for(state="visible", timeout=60000)
  1187. shop_license_img = shop_license_div.locator('img')
  1188. try:
  1189. if shop_license_img.count() > 0:
  1190. shop_license_src = shop_license_img.get_attribute('src')
  1191. shop_license_src = shop_license_src.strip() if shop_license_src else None
  1192. ocr_res = get_ocr_res(shop_license_src)
  1193. # print(f'ocr_res:{ocr_res}')
  1194. else:
  1195. shop_license_src = None
  1196. except Exception as e:
  1197. # 捕获定位/提取失败的异常,避免程序崩溃
  1198. logger.warning(f"提取营业执照图片src失败:{e}")
  1199. shop_license_src = None
  1200. print("营业执照图片链接:", shop_license_src)
  1201. # input("..")
  1202. contact_address = ''
  1203. qualification_number = ocr_res.get('社会信用代码', '') if ocr_res else ''
  1204. business_license_company = ocr_res.get('单位名称', '') if ocr_res else ''
  1205. business_license_address = ocr_res.get('地址', '') if ocr_res else ''
  1206. # scrape_date = ''
  1207. # 调用提取函数,获取省份和城市
  1208. province, city = extract_province_city(business_license_address)
  1209. logger.info(f"原始地址:{business_license_address}")
  1210. logger.info(f"提取的省份:{province} | 城市:{city}")
  1211. insert_result = insert_shop_info_to_db(
  1212. shop=shop,
  1213. contact_address=contact_address,
  1214. qualification_number=qualification_number,
  1215. business_license_company=business_license_company,
  1216. business_license_address=business_license_address,
  1217. scrape_date=current_time,
  1218. platform=platform,
  1219. province=province,
  1220. city=city,
  1221. create_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S") ,
  1222. update_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  1223. )
  1224. elif shop == "药品预约中心":
  1225. province, city = "未知", "未知"
  1226. business_license_company, qualification_number = "", ""
  1227. if shop_info:
  1228. province = shop_info["province"]
  1229. city = shop_info["city"]
  1230. business_license_company = shop_info["business_license_company"]
  1231. qualification_number = shop_info["qualification_number"]
  1232. else:
  1233. logger.info("数据库有该店名,在数据库拿取对应字段填充yjj_drug_middle表")
  1234. province, city = "", ""
  1235. business_license_company, qualification_number = "", ""
  1236. if shop_info:
  1237. province = shop_info["province"]
  1238. city = shop_info["city"]
  1239. business_license_company = shop_info["business_license_company"]
  1240. qualification_number = shop_info["qualification_number"]
  1241. # ========== 关闭新标签页,切回列表页 ==========
  1242. if detail_page and not detail_page.is_closed():
  1243. detail_page.close() # 关闭详情页标签
  1244. logger.info(f"📌 「{keyword}」第{collected_count}个商品 - 已关闭详情页标签页")
  1245. # 切回原列表页(第一个标签页)
  1246. page.bring_to_front() # 激活列表页
  1247. page.mouse.move(random.randint(100, 300), random.randint(200, 400)) # 随机移动鼠标
  1248. random_delay(0.5, 1.0) # 增加切换后延迟
  1249. page.wait_for_load_state("networkidle")
  1250. random_delay(MIN_CLICK_DELAY, MAX_CLICK_DELAY)
  1251. logger.info(f" 「{keyword}」第{collected_count}个商品「{title}」- 已切回列表页")
  1252. # credit_code = ""
  1253. availability = ""
  1254. # 组装单条数据(仅新增生产日期/批准文号字段,原有字段顺序/逻辑不变)
  1255. # 构造单条数据元组(适配MySQL字段)
  1256. single_data = {
  1257. # 核心商品信息
  1258. "product": title, # 商品名称
  1259. "my_good_price": merged_price, # 自定义价格(可与min_price相同或单独提取)
  1260. "min_price": discount_price_val, # 最低价格
  1261. "manufacture_date": manufacture_date, # 生产日期
  1262. "expiry_date": expiry_date, # 有效期
  1263. "shop": shop, # 店铺名
  1264. "business_license_company": business_license_company, # 营业执照主体(公司名称)
  1265. "province": province, # 省份
  1266. "city": city, # 城市
  1267. "manufacturer": manufacturer, # 生产厂家
  1268. "specification": spec, # 规格
  1269. "approval_number": approval_number, # 批准文号
  1270. "product_link": product_link, # 商品链接
  1271. "scrape_date": current_time, # 采集日期
  1272. "scrape_province": "", # 采集省份(可留空或根据IP获取)
  1273. "availability": availability, # 库存状态
  1274. "credit_code": qualification_number, # 统一信用代码(如有可补充提取)
  1275. "platform": platform, # 平台名称(固定或动态获取)
  1276. "search_key": keyword, # 搜索关键词
  1277. "number": num, # 数量(盒数)
  1278. "is_sold_out": is_sold_out, # 售罄标记(0/1)
  1279. "sales": sell, #销量
  1280. "inventory": storage, #库存
  1281. "snapshot_url": oss_url, #快照链接
  1282. "update_time": time.strftime("%Y-%m-%d %H:%M:%S"), # 更新时间
  1283. "insert_time": time.strftime("%Y-%m-%d %H:%M:%S"), # 创建时间
  1284. "task_id": keyword_dict["id"], # 任务id
  1285. "company_id": keyword_dict["company_id"], # 平台id
  1286. "product_brand": keyword_dict["product_brand"], # 创建时间
  1287. "search_name": keyword_dict["product_name"], # 创建时间
  1288. "collect_config_info": json.dumps(
  1289. {"sampling_cycle": keyword_dict["sampling_cycle"], "sampling_start_time":keyword_dict["sampling_start_time"],
  1290. "sampling_end_time":keyword_dict["sampling_end_time"]}),
  1291. "collect_equipment_account_id": keyword_dict["collect_equipment_account_id"],
  1292. "collect_region_id": keyword_dict["collect_region_id"],
  1293. "collect_round": keyword_dict["collect_round"],
  1294. }
  1295. # 调用逐条插入函数
  1296. insert_single_to_mysql(single_data)
  1297. collect_result.append(single_data)
  1298. logger.info(f" 「{keyword}」第{collected_count}个商品「{title}」采集完成")
  1299. except Exception as e:
  1300. kw = keyword
  1301. if kw not in error_keywords:
  1302. error_keywords.append(kw)
  1303. _report_exception(scheduler, keyword_dict, page_no, 5, f"商品采集异常: {e}", crawled_count=len(collect_result))
  1304. logger.exception(f" 「{keyword}」第{collected_count}个商品采集核心异常:{str(e)}")
  1305. try:
  1306. if detail_page and not detail_page.is_closed():
  1307. detail_page.close()
  1308. logger.info(f"📌 「{keyword}」第{collected_count}个商品 - 异常时关闭详情页标签页")
  1309. if page and not page.is_closed():
  1310. page.bring_to_front() # 切回列表页
  1311. page.wait_for_load_state("networkidle")
  1312. random_delay(MIN_CLICK_DELAY, MAX_CLICK_DELAY)
  1313. except Exception as e2:
  1314. logger.error(f" 「{keyword}」第{collected_count}个商品详情采集异常(处理时):{str(e2)},原异常:{str(e)}")
  1315. continue
  1316. # ✅ 每15次滚动一次(修复:用collected_count,且排除0的情况)
  1317. if collected_count % 5 == 0 and collected_count > 0 and collected_count != total_limit:
  1318. logger.info("采满5个往下滑")
  1319. slow_scroll_400px(page,)
  1320. page.wait_for_load_state("networkidle")
  1321. # ====== 当前页采集完毕,逐页回告 ======
  1322. if _report_page_progress(scheduler, keyword_dict, page_no, len(collect_result)) == True:
  1323. logger.error("逐页回告返回error,停止采集")
  1324. break
  1325. delay = random_delay(1.5, 3.0)
  1326. logger.info(f"⏳ 翻页前随机等待 {delay:.2f}s(反爬)")
  1327. if goto_next_page(page):
  1328. page_no += 1
  1329. continue
  1330. else:
  1331. logger.info(f" 「{keyword}」已无下一页,关键词采集结束")
  1332. break
  1333. # 关键词采集完成回告
  1334. _report_page_progress(scheduler, keyword_dict, page_no, len(collect_result),
  1335. is_finished=1, total_pages=page_no)
  1336. long_delay = random_delay(MIN_KEYWORD_DELAY, MAX_KEYWORD_DELAY)
  1337. logger.info(f" 「{keyword}」采集完成,共{len(collect_result)}条数据,等待{long_delay:.2f}秒后继续下一个关键词(反爬)")
  1338. result_data = {
  1339. "real_count": len(collect_result),
  1340. "end_page": page_no,
  1341. "start_page": 1,
  1342. }
  1343. return result_data
  1344. # ==================== 主函数(登录+调度器拉任务) ====================
  1345. def main():
  1346. logger.info("\n" + "="*50)
  1347. logger.info("🚀 药九九采集程序启动")
  1348. logger.info(f"⏰ 启动时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
  1349. logger.info("="*50)
  1350. # 从数据库获取可用账号
  1351. account = get_account_from_db()
  1352. if not account:
  1353. logger.error("无可用药九九账号,退出本轮")
  1354. return
  1355. device_id = account["phone"] # phone 作为 DEVICE_ID,回告接口的 username 也是这个
  1356. username = account["phone"]
  1357. password = account["password"]
  1358. logger.info("获取到账号: %s, phone=%s", account["name"], device_id)
  1359. # 创建调度器,启动心跳(先上报心跳再拉任务)
  1360. scheduler = CrawlerScheduler(DEVICE_ID=username, platform=PLATFORM_ID)
  1361. scheduler.start()
  1362. time.sleep(2)
  1363. logger.info("调度器已启动,心跳已就绪")
  1364. with sync_playwright() as p:
  1365. browser = p.chromium.launch(
  1366. headless=False,
  1367. channel="chrome",
  1368. slow_mo=random.randint(100, 300),
  1369. args=_chromium_launch_args(),
  1370. )
  1371. context = browser.new_context(
  1372. locale="zh-CN",
  1373. timezone_id="Asia/Shanghai",
  1374. geolocation={"latitude": 31.230416, "longitude": 121.473701},
  1375. permissions=["geolocation"],
  1376. user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
  1377. viewport={"width": 1600, "height": 1400},
  1378. java_script_enabled=True,
  1379. bypass_csp=True,
  1380. )
  1381. page = context.new_page()
  1382. page.add_init_script("""
  1383. Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
  1384. Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3] });
  1385. Object.defineProperty(navigator, 'mimeTypes', { get: () => [1, 2, 3] });
  1386. window.chrome = { runtime: {}, loadTimes: () => ({}) };
  1387. delete window.navigator.languages;
  1388. window.navigator.languages = ['zh-CN', 'zh'];
  1389. (() => {
  1390. const originalAddEventListener = EventTarget.prototype.addEventListener;
  1391. EventTarget.prototype.addEventListener = function(type, listener) {
  1392. if (type === 'mousemove') {
  1393. return originalAddEventListener.call(this, type, (e) => {
  1394. e._automation = undefined;
  1395. listener(e);
  1396. });
  1397. }
  1398. return originalAddEventListener.call(this, type, listener);
  1399. };
  1400. })();
  1401. """)
  1402. keyword_dict = None
  1403. try:
  1404. # ========== Cookie复用 + 登录 ==========
  1405. load_cookies(context)
  1406. if not is_login(page):
  1407. page.goto(TARGET_LOGIN_URL)
  1408. time.sleep(5)
  1409. page.wait_for_load_state("networkidle")
  1410. logger.info("🔑 开始执行登录流程,账号=%s", username)
  1411. login_success = login_operation(page, username, password)
  1412. if not login_success:
  1413. logger.error("登录失败,程序终止")
  1414. _report_exception(scheduler, {}, 0, 1, "登录失败")
  1415. return
  1416. save_cookies(context)
  1417. logger.info("登录并保存Cookie成功!")
  1418. # ========== 循环拉取任务 ==========
  1419. while True:
  1420. task = scheduler.get_task()
  1421. if not task:
  1422. logger.info("暂无任务,本轮结束")
  1423. break
  1424. # 构建 keyword_dict(兼容原有字段)
  1425. current_page = task.get("current_page", 0)
  1426. start_page = current_page if current_page > 0 else 1
  1427. keyword_dict = {
  1428. "id": task.get("id"),
  1429. "company_id": task.get("company_id"),
  1430. "product_name": task.get("product_name", ""),
  1431. "product_specs": task.get("product_specs", ""),
  1432. "product_brand": task.get("product_brand", ""),
  1433. "product_keyword": task.get("product_keyword", ""),
  1434. "current_page": current_page,
  1435. "start_offset": task.get("start_offset", 0),
  1436. "start_page": start_page,
  1437. "end_page": 0,
  1438. "_platform": PLATFORM_ID,
  1439. "_device_id": device_id,
  1440. "collect_task_id": str(task.get("id", "")),
  1441. "snapshot_collect_status": 1,
  1442. "sampling_cycle": "",
  1443. "sampling_start_time": "",
  1444. "sampling_end_time": "",
  1445. "collect_equipment_account_id": 0,
  1446. "collect_region_id": 0,
  1447. "collect_equipment_id": 0,
  1448. "collect_round": 0,
  1449. }
  1450. keyword = f"{keyword_dict['product_brand']} {keyword_dict['product_name']} {keyword_dict['product_specs']}".strip()
  1451. logger.info(f"\n{'='*40}\n🔍 开始处理:{keyword}\n{'='*40}")
  1452. # 执行搜索
  1453. popup_guard(page, "before_search")
  1454. search_success = search_operation(page, keyword)
  1455. popup_guard(page, "after_search")
  1456. if not search_success:
  1457. logger.warning(f"「{keyword}」搜索失败,跳过")
  1458. _report_exception(scheduler, keyword_dict, 0, 4, "搜索失败")
  1459. continue
  1460. page.wait_for_load_state("domcontentloaded")
  1461. page.wait_for_load_state('networkidle')
  1462. # 采集数据(内部含逐页回告)
  1463. collect_data(page, keyword, keyword_dict, scheduler=scheduler)
  1464. finished_task_ids.add(keyword_dict["id"])
  1465. logger.info(f"任务 {keyword_dict['id']} 完成")
  1466. # 任务间隔
  1467. time.sleep(random.randint(180, 300))
  1468. except Exception as e:
  1469. if keyword_dict is not None:
  1470. _report_exception(scheduler, keyword_dict, 0, 5, f"程序异常: {e}")
  1471. logger.exception("程序异常:%s", e)
  1472. finally:
  1473. browser.close()
  1474. logger.info("浏览器已关闭,程序结束")
  1475. # ==================== 程序入口 ====================
  1476. if __name__ == '__main__':
  1477. while True:
  1478. try:
  1479. print(f"【任务开始】时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
  1480. main()
  1481. print(f"【任务结束】时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
  1482. except Exception as e:
  1483. print(f"【异常】: {e}")
  1484. time.sleep(random.randint(180, 300))