3
0

yjj_v2.py 75 KB

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