yyjzt_crawl.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. """药九九搜索爬虫 — 通过 API 接口采集商品价格,缺店铺信息时自动补齐"""
  2. import hashlib
  3. import json
  4. import random
  5. import time
  6. import requests
  7. from commons.Logger import get_spider_logger
  8. from commons.conn_mysql import MySQLPoolOn2
  9. from pipelines.drug_pipelines import DrugPipeline
  10. from pipelines.shop_pipelines import ShopPipeline
  11. from spiders.yaojiujiu.login_yaojiujiu import YaoJiuJiuLogin
  12. logger = get_spider_logger("yaojiujiu")
  13. # ---------- 常量 ----------
  14. PLATFORM_ID = 7
  15. SEARCH_API = "https://web-api.yyjzt.com/search/api/search/item/list"
  16. STORE_API = "https://web-api.yyjzt.com/sale/api/Sale/storeinfo/findById"
  17. PRODUCT_LINK_TPL = (
  18. "https://www.yyjzt.com/goodDetail?ladderNum"
  19. "&itemStoreId={item_store_id}"
  20. "&sourceProdetail=%2Fsearch"
  21. "&is_store=0"
  22. )
  23. PAGE_SIZE = 60
  24. MAX_PAGES = 30
  25. def compute_one_price(item):
  26. """
  27. 完全复刻页面 gc-l2-price 展示逻辑:
  28. 有活动(特价/团购) → sortPrice(活动价)
  29. 无活动(普通) → itemPrice(原价)
  30. """
  31. discount = item.get("marketItemStoreDiscountPriceToTrade") or {}
  32. if discount.get("specialPrice") is not None:
  33. return float(discount["specialPrice"])
  34. if discount.get("joinGroupPrice") is not None:
  35. return float(discount["joinGroupPrice"])
  36. return float(item.get("itemPrice") or 0)
  37. def generate_sign(token, body_dict, ts):
  38. """_s 签名: MD5(token + JSON.stringify(body) + timestamp)"""
  39. body_json = json.dumps(body_dict, separators=(",", ":"), ensure_ascii=False)
  40. raw = token + body_json + str(ts)
  41. return hashlib.md5(raw.encode("utf-8")).hexdigest()
  42. class YyjztSpider:
  43. def __init__(self, task_dict=None):
  44. self.task_dict = task_dict or {}
  45. self.pipeline = DrugPipeline("yaojiujiu")
  46. self.shop_pipeline = ShopPipeline("yaojiujiu")
  47. self.db = MySQLPoolOn2()
  48. self.page = 0
  49. self.is_success = True
  50. self.collect_task_id = None
  51. self._seen_stores = set() # 已处理的 storeId,避免重复查库/调接口
  52. # ---- 从 task 提取参数 ----
  53. self.task_id = ""
  54. self.company_id = ""
  55. self.product_name = ""
  56. self.product_specs = ""
  57. self.product_brand = ""
  58. self.keyword = ""
  59. self.sampling_cycle = ""
  60. self.sampling_start_time = ""
  61. self.sampling_end_time = ""
  62. self.account_id = ""
  63. self.collect_region_id = ""
  64. self.collect_round = "1"
  65. if self.task_dict:
  66. self._parse_task()
  67. # ==================== 任务解析 ====================
  68. def _parse_task(self):
  69. self.task_id = self.task_dict.get("id", "")
  70. self.company_id = self.task_dict.get("company_id", "")
  71. self.product_name = self.task_dict.get("product_name", "")
  72. self.product_specs = self.task_dict.get("product_specs", "")
  73. self.product_brand = self.task_dict.get("product_brand", "")
  74. self.keyword = (
  75. self.task_dict.get("product_keyword", "")
  76. or (self.task_dict.get("product_brand", "").strip() + " " + self.product_name.strip() + " " + self.product_specs.strip()).strip()
  77. ).strip()
  78. self.sampling_cycle = self.task_dict.get("sampling_cycle", "")
  79. self.sampling_start_time = self.task_dict.get("sampling_start_time", "")
  80. self.sampling_end_time = self.task_dict.get("sampling_end_time", "")
  81. self.account_id = self.task_dict.get("collect_equipment_account_id", "")
  82. self.collect_region_id = self.task_dict.get("collect_region_id", "")
  83. self.collect_round = self.task_dict.get("collect_round", "1")
  84. self.collect_task_id = self.task_dict.get("collect_task_id", "")
  85. # 登录账号
  86. self._account_phone = self.task_dict.get("account_phone", "")
  87. self._account_password = self.task_dict.get("account_password", "")
  88. # deviceId 用账号 phone 或固定值
  89. self._device_id = self._account_phone or "yyjzt_device"
  90. # token 从 accounts_platform 缓存读取,过期后自动重登
  91. self._token = None
  92. self._ensure_token()
  93. # ==================== 进度回告 ====================
  94. def _report_exception(self, exception_type, remark=""):
  95. scheduler = self.task_dict.get("scheduler")
  96. if not scheduler:
  97. return
  98. data = {
  99. "task_id": self.task_id,
  100. "platform": str(PLATFORM_ID),
  101. "username": self.task_dict.get("_device_id", ""),
  102. "current_page": self.page,
  103. "is_finished": 0,
  104. "exception_type": exception_type,
  105. "remark": remark,
  106. }
  107. if self.pipeline.crawl_count:
  108. data["crawled_count"] = self.pipeline.crawl_count
  109. scheduler.post_report(data)
  110. def _report_page_progress(self, page, crawled_count, is_finished=0, total_pages=0):
  111. scheduler = self.task_dict.get("scheduler")
  112. if not scheduler:
  113. return False
  114. data = {
  115. "task_id": self.task_id,
  116. "platform": str(PLATFORM_ID),
  117. "username": self.task_dict.get("_device_id", ""),
  118. "current_page": page,
  119. "crawled_count": crawled_count,
  120. "is_finished": is_finished,
  121. }
  122. if total_pages:
  123. data["total_pages"] = total_pages
  124. scheduler.post_report(data)
  125. return scheduler.end
  126. # ==================== 登录 & Token 管理 ====================
  127. # 参考 main.py is_login() — 主动检测,不等到 API 报错
  128. def _is_token_valid(self):
  129. """用轻量 API 主动检测 token 是否有效(类似 main.py 检测'欢迎登录'文本)"""
  130. if not self._token:
  131. return False
  132. body = {"pageIndex": 1, "pageSize": 1, "platformType": 1,
  133. "keyword": "999感冒灵", "searchNum": 0, "showSearchTips": 0, "isNeedAggregation": 0}
  134. try:
  135. headers = self._signed_headers(body)
  136. resp = requests.post(SEARCH_API, json=body, headers=headers, timeout=10)
  137. data = resp.json()
  138. return data.get("success") and data.get("code") == 200
  139. except Exception:
  140. return False
  141. def _get_token_from_db(self):
  142. """直接从 DB 读缓存 token"""
  143. sql = """SELECT `cookie_str` FROM `accounts_platform`
  144. WHERE `platform`=%s AND `status`=1 AND `phone`=%s
  145. ORDER BY `cookie_timestamp` DESC LIMIT 1"""
  146. rows = self.db.select_data(sql, (PLATFORM_ID, self._account_phone))
  147. if not rows:
  148. return None
  149. try:
  150. cd = json.loads(rows[0].get("cookie_str") or "{}")
  151. return cd.get("Token") or cd.get("token")
  152. except json.JSONDecodeError:
  153. return None
  154. def _ensure_token(self):
  155. """先读 DB 缓存 → 主动验证是否过期 → 过期则浏览器重新登录"""
  156. # 1. 读缓存(类似 main.py load_cookies)
  157. token = self._get_token_from_db()
  158. if token:
  159. self._token = token
  160. # 2. 主动检测是否有效(类似 main.py is_login)
  161. if self._is_token_valid():
  162. logger.info("缓存 token 有效: %s...", token[:16])
  163. return
  164. else:
  165. logger.info("缓存 token 已过期")
  166. # 3. 过期或不存在 → 浏览器登录(类似 main.py login_operation + save_cookies)
  167. logger.info("启动浏览器登录...")
  168. login = YaoJiuJiuLogin()
  169. token = login.run(headless=True)
  170. if token:
  171. self._token = token
  172. else:
  173. logger.error("登录失败")
  174. self._token = ""
  175. def _re_login(self):
  176. """Token 失效,强制重新登录"""
  177. logger.info("Token 失效,重新登录...")
  178. login = YaoJiuJiuLogin()
  179. token = login.run(headless=True)
  180. if token:
  181. self._token = token
  182. return True
  183. return False
  184. # ==================== API 请求头 ====================
  185. def _base_headers(self):
  186. return {
  187. "Origin": "https://www.yyjzt.com",
  188. "Referer": "https://www.yyjzt.com/",
  189. "sy_plat": "B2B",
  190. "token_platform_client_type": "USER",
  191. "zhcaiToken": self._token,
  192. "deviceId": self._device_id,
  193. "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
  194. }
  195. def _signed_headers(self, body):
  196. ts = int(time.time() * 1000)
  197. sign = generate_sign(self._token, body, ts)
  198. headers = self._base_headers()
  199. headers["Content-Type"] = "application/json"
  200. headers["_s"] = sign
  201. headers["_t"] = str(ts)
  202. return headers
  203. # ==================== 搜索 API ====================
  204. def _search_page(self, page_index, retry=True):
  205. if not self._token:
  206. raise Exception("无登录 token")
  207. body = {
  208. "pageIndex": page_index,
  209. "pageSize": PAGE_SIZE,
  210. "platformType": 1,
  211. "keyword": self.keyword,
  212. "searchNum": 0,
  213. "showSearchTips": 0,
  214. "isNeedAggregation": 0,
  215. }
  216. headers = self._signed_headers(body)
  217. resp = requests.post(SEARCH_API, json=body, headers=headers, timeout=30)
  218. resp.raise_for_status()
  219. data = resp.json()
  220. # 登录失效则重登后重试一次
  221. if not (data.get("success") and data.get("code") == 200):
  222. msg = data.get("msg", "")
  223. if retry and ("登录" in msg or "token" in msg.lower() or "未登录" in msg):
  224. logger.warning("Token 失效(%s),尝试重新登录", msg)
  225. if self._re_login():
  226. return self._search_page(page_index, retry=False)
  227. raise Exception(f"API error: {msg}")
  228. return data["data"]["itemList"]
  229. # ==================== 店铺信息补齐 ====================
  230. def _ensure_shop_info(self, store_id, store_name):
  231. """确保店铺信息已入库。先查 retrieve_scrape_shop_info,没有则调 API 抓取。"""
  232. if not store_id or store_id in self._seen_stores:
  233. return
  234. self._seen_stores.add(store_id)
  235. # 查库
  236. exist = self.db.select_data(
  237. "SELECT 1 FROM retrieve_scrape_shop_info WHERE platform=%s AND shop=%s LIMIT 1",
  238. (str(PLATFORM_ID), store_name),
  239. )
  240. if exist:
  241. return
  242. # 调店铺信息接口
  243. logger.info("店铺表无 [%s],抓取店铺信息 storeId=%s", store_name, store_id)
  244. try:
  245. resp = requests.get(
  246. STORE_API,
  247. params={"id": store_id},
  248. headers=self._base_headers(),
  249. timeout=15,
  250. )
  251. resp.raise_for_status()
  252. data = resp.json()
  253. if not (data.get("success") and data.get("code") == 200):
  254. logger.warning("店铺接口异常 storeId=%s: %s", store_id, data.get("msg"))
  255. return
  256. info = data["data"]
  257. except Exception as e:
  258. logger.error("店铺接口请求失败 storeId=%s: %s", store_id, e)
  259. return
  260. # 映射到 retrieve_scrape_shop_info 字段
  261. today = time.strftime("%Y-%m-%d")
  262. shop_row = {
  263. "id": PLATFORM_ID,
  264. "platform": str(PLATFORM_ID),
  265. "shop": (info.get("storeShortName") or info.get("storeTitle") or store_name),
  266. "province": info.get("provinceName") or "",
  267. "city": info.get("cityName") or "",
  268. "contact_address": info.get("storeAddress") or "",
  269. "qualification_number": info.get("organizationCode") or "",
  270. "business_license_company": info.get("storeName") or "",
  271. "business_license_address": info.get("storeAddress") or "",
  272. "scrape_date": today,
  273. "ai_generate": 0,
  274. }
  275. try:
  276. self.shop_pipeline.storge_data(shop_row)
  277. except Exception as e:
  278. logger.exception("店铺入库失败 storeId=%s: %s", store_id, e)
  279. # 店铺接口休息 1-2 秒
  280. time.sleep(random.uniform(1, 2))
  281. # ==================== 数据映射 ====================
  282. def _to_product(self, item):
  283. now = time.strftime("%Y-%m-%d %H:%M:%S")
  284. today = time.strftime("%Y-%m-%d")
  285. store_name = item.get("storeName") or ""
  286. store_full_name = item.get("storeFullName") or ""
  287. item_store_id = str(item.get("itemStoreId") or "")
  288. # 省份/城市/area_info 由 DrugPipeline.get_shop_city() 从 retrieve_scrape_shop_info 表补齐
  289. return {
  290. "platform": PLATFORM_ID,
  291. "item_id": item_store_id,
  292. "enterprise_id": self.company_id,
  293. "product_name": item.get("itemStoreName") or "",
  294. "spec": item.get("specs") or "",
  295. "one_price": compute_one_price(item),
  296. "detail_url": PRODUCT_LINK_TPL.format(item_store_id=item_store_id) if item_store_id else "",
  297. "shop_name": store_name,
  298. "anonymous_store_name": store_name,
  299. "shop_url": "",
  300. "city_name": "",
  301. "city_id": "",
  302. "province_name": "",
  303. "province_id": "",
  304. "area_info": "",
  305. "factory_name": item.get("manufacturer") or item.get("manufactureName") or "",
  306. "scrape_date": today,
  307. "price": float(item.get("sortPrice") or 0),
  308. "sales": item.get("totalCustCount") or "",
  309. "stock_count": item.get("itemStorage") or item.get("ownStorageNumber") or 0,
  310. "snapshot_url": "", # API 模式无快照,快照模式后续用 Playwright 截图
  311. "approval_num": item.get("approvalNo") or "",
  312. "produced_time": "",
  313. "deadline": str(item.get("validateTimeEnd") or ""),
  314. "update_time": now,
  315. "insert_time": now,
  316. "number": int(item.get("middlePackageAmount") or 1),
  317. "product_brand": item.get("brandName") or self.product_brand,
  318. "collect_task_id": self.collect_task_id,
  319. "search_name": self.product_name,
  320. "company_name": store_full_name,
  321. "collect_config_info": json.dumps({
  322. "sampling_cycle": self.sampling_cycle,
  323. "sampling_start_time": self.sampling_start_time,
  324. "sampling_end_time": self.sampling_end_time,
  325. }, ensure_ascii=False),
  326. "account_id": self.account_id,
  327. "collect_region_id": self.collect_region_id,
  328. "collect_round": self.collect_round,
  329. "is_sold_out": 1 if item.get("isSoldOut") else 0,
  330. }
  331. # ==================== 主流程 ====================
  332. def _match_item(self, product):
  333. product_brand = product.get("product_brand", "")
  334. product_name = product.get("product_name", "")
  335. product_specs = product.get("spec", "")
  336. brand_ok = True
  337. name_ok = True
  338. specs_ok = True
  339. if self.product_brand.strip():
  340. brand_ok = self.product_brand.strip() in str(product_brand)
  341. if self.product_name.strip():
  342. name_ok = self.product_name.strip() in str(product_name)
  343. if self.product_specs.strip():
  344. specs_ok = self.product_specs.strip() in str(product_specs)
  345. return brand_ok and name_ok and specs_ok
  346. def search_data(self):
  347. if not self.task_dict:
  348. return
  349. no_match_count = 0 # 连续不匹配计数
  350. MAX_NO_MATCH = 20 # 连续超过此数则停止(参考 main.py)
  351. pages = [] # 实际爬取过的页数
  352. for page in range(1, MAX_PAGES + 1):
  353. logger.info("药九九 爬取第 %s 页, 关键词=%s", page, self.keyword)
  354. try:
  355. items = self._search_page(page)
  356. except Exception as e:
  357. logger.error("第 %s 页请求失败: %s", page, e)
  358. self.is_success = False
  359. self._report_exception(5, f"第{page}页请求失败: {e}")
  360. return
  361. if not items:
  362. logger.info("第 %s 页无数据,停止翻页")
  363. break
  364. pages.append(page)
  365. self.page = page
  366. # 先补齐所有店铺信息
  367. for item in items:
  368. store_id = item.get("storeId")
  369. store_name = item.get("storeName") or ""
  370. self._ensure_shop_info(store_id, store_name)
  371. # 逐条入库
  372. for item in items:
  373. product = self._to_product(item)
  374. if not product.get("item_id"):
  375. continue
  376. # 品牌/品名过滤
  377. if not self._match_item(product):
  378. no_match_count += 1
  379. logger.warning("不匹配(%s/%s): brand=%s name=%s",
  380. no_match_count, MAX_NO_MATCH,
  381. product.get("product_brand"), product.get("product_name"))
  382. if no_match_count >= MAX_NO_MATCH:
  383. logger.error("连续 %s 条不匹配,停止翻页", MAX_NO_MATCH)
  384. # 回告当前页进度后再退出
  385. self._report_page_progress(page, self.pipeline.crawl_count)
  386. return
  387. continue
  388. no_match_count = 0 # 匹配成功则重置
  389. try:
  390. self.pipeline.storge_data(product)
  391. except Exception as e:
  392. logger.exception("入库失败: %s", e)
  393. if self._report_page_progress(page, self.pipeline.crawl_count):
  394. break
  395. if len(items) < PAGE_SIZE:
  396. break
  397. # 翻页休息 2-3 秒,模拟真人
  398. time.sleep(random.uniform(2, 3))
  399. # 用实际爬取页数回告完成
  400. total_pages = len(pages)
  401. if total_pages > 0:
  402. self._report_page_progress(pages[-1], self.pipeline.crawl_count,
  403. is_finished=1, total_pages=total_pages)
  404. def run(self):
  405. try:
  406. self.search_data()
  407. except Exception as e:
  408. self.is_success = False
  409. logger.error("药九九爬虫异常: %s", e, exc_info=True)
  410. self._report_exception(5, f"未知异常: {e}")
  411. logger.info("药九九 爬取完成: %s 条, %s 页", self.pipeline.crawl_count, self.page)
  412. return self.pipeline.crawl_count, self.is_success, self.page