taobao_crawl.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604
  1. import hashlib
  2. import json
  3. import random
  4. import re
  5. import time
  6. from decimal import Decimal, InvalidOperation
  7. from curl_cffi import requests
  8. from lxml import etree
  9. from commons.Logger import get_spider_logger
  10. from commons.conn_mysql import MySQLPoolOn2
  11. from pipelines.drug_pipelines import DrugPipeline
  12. from spiders.taobao.taobao_login import (TaobaoAutoCrawl)
  13. from area_info.city_name_to_id import get_city
  14. from urllib.parse import unquote
  15. from commons.config import (
  16. MTOP_APP_KEY,
  17. MTOP_APP_ID,
  18. TAOBAO_SEARCH_MAX_PAGE,
  19. TAOBAO_REQUEST_RETRY_COUNT,
  20. TAOBAO_COOKIE_MAX_AGE_SEC,
  21. UA_TAOBAO,
  22. TAOBAO_NO_MATCH_THRESHOLD,
  23. )
  24. logger = get_spider_logger("taobao")
  25. SEARCH_MAX_PAGE = TAOBAO_SEARCH_MAX_PAGE
  26. REQUEST_RETRY_COUNT = TAOBAO_REQUEST_RETRY_COUNT
  27. COOKIE_MAX_AGE_SEC = TAOBAO_COOKIE_MAX_AGE_SEC
  28. headers = {
  29. "accept": "*/*",
  30. "accept-language": "zh-CN,zh;q=0.9",
  31. "referer": "https://s.taobao.com/search?page=1&q=999%E6%84%9F%E5%86%92%E7%81%B5&spm=a21bo.jianhua%2Fa.201867-main.d4_first.42f72a89n1ITMs&tab=mall",
  32. "sec-ch-ua": '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"',
  33. "sec-ch-ua-mobile": "?0",
  34. "sec-ch-ua-platform": '"Windows"',
  35. "sec-fetch-dest": "script",
  36. "sec-fetch-mode": "no-cors",
  37. "sec-fetch-site": "same-site",
  38. "user-agent": (
  39. UA_TAOBAO,
  40. ),
  41. }
  42. MTOP_URL = (
  43. "https://h5api.m.taobao.com/h5/mtop.relationrecommend.wirelessrecommend.recommend/2.0/"
  44. )
  45. def get_product_name(item):
  46. """递归查找 item 中 propertyName='产品名称' 的 propertyValueName"""
  47. if isinstance(item, dict):
  48. if item.get('propertyName') == '产品名称':
  49. return item.get('propertyValueName', '')
  50. for v in item.values():
  51. result = get_product_name(v)
  52. if result:
  53. return result
  54. elif isinstance(item, list):
  55. for sub in item:
  56. result = get_product_name(sub)
  57. if result:
  58. return result
  59. return ''
  60. class TaobaoCrawl:
  61. def __init__(self, drug_dict=None,scheduler = None):
  62. self.cookies = None
  63. self.db = MySQLPoolOn2()
  64. self.pipeline = DrugPipeline("taobao")
  65. self.session = None
  66. self.proxies = None
  67. self.account_name = None
  68. self.ip = None
  69. self.cookie_stamp = None
  70. self.platform = 1
  71. self.task_dict = drug_dict or {}
  72. self.collect_task_id = None
  73. self.success = True
  74. self.scheduler = scheduler
  75. if self.task_dict:
  76. self.get_product_data()
  77. self.is_no_product = 0
  78. def get_product_data(self):
  79. self.task_id = self.task_dict["id"]
  80. self.company_id = self.task_dict["company_id"]
  81. self.product = self.task_dict["product_name"]
  82. self.product_desc = self.task_dict.get("product_specs", "")
  83. self.brand = self.task_dict.get("product_brand", "")
  84. self.product_keyword = self.task_dict.get("product_keyword", "")
  85. self.collect_task_id = self.task_dict.get("collect_task_id", "")
  86. self.sampling_cycle = self.task_dict.get("sampling_cycle", "")
  87. self.sampling_start_time = self.task_dict.get("sampling_start_time", "")
  88. self.sampling_end_time = self.task_dict.get("sampling_end_time", "")
  89. self.collect_equipment_id = self.task_dict.get("collect_equipment_id", "")
  90. self.account_id = self.task_dict.get("collect_equipment_account_id", "")
  91. self.collect_region_id = self.task_dict.get("collect_region_id", "")
  92. self.collect_round = self.task_dict.get("collect_round", 1)
  93. self.start_page = self.task_dict.get(self.task_dict.get("current_page")+1, 1)
  94. self.report_data = {'task_id':self.task_id,'platform':self.platform,'username':self.scheduler.username}
  95. def _error_report(self,data):
  96. self.report_data.update(data)
  97. self.scheduler.stop()
  98. self.scheduler.post_report(self.report_data)
  99. def _success_report(self,data):
  100. self.report_data.update(data)
  101. self.scheduler.post_report(self.report_data)
  102. @staticmethod
  103. def _normalize_url(url):
  104. if not url:
  105. return ""
  106. url = str(url)
  107. if url.startswith("//"):
  108. return "https:" + url
  109. return url
  110. @staticmethod
  111. def _extract_shop_id(shop_url):
  112. if not shop_url:
  113. return ""
  114. shop_re = re.search(r"appUid=(\w+)", shop_url)
  115. if shop_re:
  116. return shop_re.group(1)
  117. return hashlib.md5(shop_url.encode("utf-8")).hexdigest()
  118. @staticmethod
  119. def _sql_literal(value):
  120. """避免拼接账号名时单引号打断 SQL(非完整防注入,仅兜底)。"""
  121. if value is None:
  122. return ""
  123. return str(value).replace("'", "''").replace("\\", "\\\\")
  124. def init_session(self):
  125. self.session = requests.Session(impersonate="chrome124")
  126. self.session.cookies.update(self.cookies or {})
  127. self.session.headers.update(headers)
  128. if self.proxies:
  129. self.session.proxies.update(self.proxies)
  130. def init_session_lose(self,res,keyword,page):
  131. if '哎哟喂,被挤爆啦' in res.text:
  132. if not self.update_cookie():
  133. return False
  134. res = self._request_search_page(keyword, page)
  135. if '哎哟喂,被挤爆啦' in res.text:
  136. return False
  137. return True
  138. @staticmethod
  139. def _is_transport_error(err):
  140. msg = str(err or "")
  141. return ("curl: (16)" in msg) or ("Failed to perform" in msg)
  142. def get_token(self, t, app_key, data_str):
  143. _m_h5_tk = (self.cookies or {}).get("_m_h5_tk", "")
  144. token = _m_h5_tk.split("_")[0] if _m_h5_tk else ""
  145. text = f"{token}&{t}&{app_key}&{data_str}"
  146. return hashlib.md5(text.encode()).hexdigest()
  147. def get_html_content(self, res_html):
  148. if not res_html:
  149. return ""
  150. ele_html = etree.HTML(res_html)
  151. if ele_html is None:
  152. return str(res_html)
  153. text_list = ele_html.xpath(".//text()")
  154. return "".join(text_list)
  155. def _build_search_payload(self, keyword, page, page_size=50):
  156. return {
  157. "appId": MTOP_APP_ID,
  158. "params": {
  159. "device": "HMA-AL00",
  160. "isBeta": "false",
  161. "grayHair": "false",
  162. "from": "nt_history",
  163. "brand": "HUAWEI",
  164. "info": "wifi",
  165. "index": "4",
  166. "rainbow": "",
  167. "schemaType": "auction",
  168. "elderHome": "false",
  169. "isEnterSrpSearch": "true",
  170. "newSearch": "false",
  171. "network": "wifi",
  172. "subtype": "",
  173. "hasPreposeFilter": "false",
  174. "prepositionVersion": "v2",
  175. "client_os": "Android",
  176. "gpsEnabled": "false",
  177. "searchDoorFrom": "srp",
  178. "debug_rerankNewOpenCard": "false",
  179. "homePageVersion": "v7",
  180. "searchElderHomeOpen": "false",
  181. "search_action": "initiative",
  182. "sugg": "_4_1",
  183. "sversion": "13.6",
  184. "style": "list",
  185. "ttid": "600000@taobao_pc_10.7.0",
  186. "needTabs": "true",
  187. "areaCode": "CN",
  188. "vm": "nw",
  189. "countryNum": "156",
  190. "m": "pc",
  191. "page": page,
  192. "n": 48,
  193. "q": keyword,
  194. "qSource": "url",
  195. "pageSource": "",
  196. "channelSrp": "",
  197. "tab": "all",
  198. "pageSize": str(page_size),
  199. "sourceS": "2",
  200. "ntoffset": "0",
  201. "filterTag": "",
  202. "service": "",
  203. "prop": "",
  204. "loc": "",
  205. "categoryp": "",
  206. "screenResolution": "1920x1080",
  207. "viewResolution": "1092x4722",
  208. "userAgent": headers["user-agent"],
  209. "couponUnikey": "",
  210. "subTabId": "",
  211. "np": "",
  212. "clientType": "h5",
  213. "isNewDomainAb": "false",
  214. "forceOldDomain": "false",
  215. },
  216. }
  217. def _request_search_page(self, keyword, page):
  218. t = str(int(time.time() * 1000))
  219. data = self._build_search_payload(keyword, page)
  220. data_str = json.dumps(data, separators=(",", ":"))
  221. sign = self.get_token(t, MTOP_APP_KEY, data_str)
  222. params = {
  223. "jsv": "2.7.4",
  224. "appKey": MTOP_APP_KEY,
  225. "t": t,
  226. "sign": sign,
  227. "api": "mtop.relationrecommend.wirelessrecommend.recommend",
  228. "v": "2.0",
  229. "timeout": "10000",
  230. "type": "jsonp",
  231. "dataType": "jsonp",
  232. "callback": "",
  233. "data": data_str,
  234. }
  235. return self.session.get(MTOP_URL, params=params, timeout=30)
  236. def _parse_jsonp_body(self, res_text):
  237. res_text = (res_text or "").strip()
  238. json_str = res_text
  239. m = re.match(r"^[^(]*\((.*)\)\s*;?\s*$", res_text, re.DOTALL)
  240. if m:
  241. json_str = m.group(1)
  242. return json.loads(json_str)
  243. def get_search(self):
  244. keyword = self.product
  245. if self.brand:
  246. keyword = (self.brand + " " + self.product).strip()
  247. if self.product_desc:
  248. keyword = (keyword + " " + self.product_desc).strip()
  249. for page in range(self.start_page,30):
  250. logger.info(f"正在爬取关键词:{keyword},{page}页数据")
  251. for attempt in range(1, REQUEST_RETRY_COUNT + 1):
  252. try:
  253. if not self.session:
  254. self.init_session()
  255. res = self._request_search_page(keyword, page)
  256. if not self.init_session_lose(res,keyword,page):
  257. #这里要警告,属于账号异常,账号即使刷新还是不恢复
  258. self.success=False
  259. self._error_report({'is_finished':0,'need_reassign':1,'current_page':self.start_page,'exception_type':2})
  260. return
  261. if res.status_code != 200:
  262. logger.warning(
  263. "请求失败 HTTP %s,第%s/%s 次重试",
  264. res.status_code,
  265. attempt,
  266. REQUEST_RETRY_COUNT,
  267. )
  268. time.sleep(random.randint(3, 8))
  269. continue
  270. except Exception as e:
  271. if self._is_transport_error(e):
  272. logger.warning(
  273. "检测到网络传输异常(curl),重建会话后重试: %s",
  274. e,
  275. )
  276. self.init_session()
  277. logger.warning(
  278. "请求异常,第%s/%s 次重试: %s",
  279. attempt,
  280. REQUEST_RETRY_COUNT,
  281. e,
  282. )
  283. # 指数退避,避免连续瞬时失败
  284. time.sleep(min(3 * attempt, 10))
  285. continue
  286. try:
  287. json_data = self._parse_jsonp_body(res.text)
  288. item_array = json_data.get("data", {}).get("itemsArray", [])
  289. break
  290. except Exception as e:
  291. logger.warning(
  292. "解析数据异常,%s 账号可能退出登录,尝试重新登录: %s",
  293. self.account_name,
  294. e,
  295. )
  296. if self.update_cookie():
  297. self.init_session()
  298. else:
  299. self._error_report({'is_finished':0,'need_reassign':1,'current_page':page,'exception_type':2})
  300. return
  301. else:
  302. logger.warning("关键词 %s 第 %s 页连续重试失败", keyword, page)
  303. continue
  304. if not item_array:
  305. logger.warning("关键词 %s 第 %s 页未获取到商品数据", keyword, page)
  306. self._success_report({'is_finished':1,'need_reassign':0,'current_page':page})
  307. return
  308. for raw in item_array:
  309. try:
  310. item_id = raw.get("item_id", "")
  311. except:
  312. break
  313. if not item_id:
  314. continue
  315. item_title = self.get_html_content(raw.get("title") or "")
  316. try:
  317. propertyValueName =get_product_name(raw)
  318. except:
  319. propertyValueName = ''
  320. if self.brand not in item_title:
  321. self.is_no_product += 1
  322. continue
  323. if propertyValueName:
  324. if self.product not in propertyValueName:
  325. self.is_no_product += 1
  326. continue
  327. else:
  328. if self.product not in item_title:
  329. self.is_no_product += 1
  330. continue
  331. if "+" in item_title:
  332. continue
  333. if self.product_desc:
  334. if self.product_desc in item_title:
  335. crawl_product_desc = self.product_desc
  336. else:
  337. crawl_product_desc = ""
  338. else:
  339. crawl_product_desc = ""
  340. self.is_no_product = 0
  341. status = 1
  342. if self.product_keyword:
  343. search_keyword_list = self.product_keyword.split(",")
  344. for search_keyword in search_keyword_list:
  345. if search_keyword.strip() not in item_title:
  346. status = 0
  347. if status == 0:
  348. continue
  349. item_price = raw.get("price")
  350. item_price_show = raw.get("priceShow", {}).get("price", 0)
  351. item_sales = raw.get("realSales") or ""
  352. sale_num = ""
  353. sales_m = re.search(r"(.*?)人付款", item_sales)
  354. if sales_m:
  355. sale_num = sales_m.group(1)
  356. item_url = self._normalize_url(raw.get("auctionURL"))
  357. match = re.search(r'skuId=([^&]*)', unquote(item_url))
  358. if match:
  359. if match.group(1)!='null':
  360. item_id = item_id + '_' + match.group(1)
  361. shop_name = raw.get("shopInfo", {}).get("title", "")
  362. area_str = (raw.get("procity", "") or "").strip()
  363. city_id, province_id, city, province = get_city(area_str)
  364. shop_url = self._normalize_url(
  365. raw.get("shopInfo", {}).get("url", "")
  366. )
  367. structured_list = raw.get("structuredUSPInfo",{})
  368. for structured in structured_list:
  369. if structured.get("propertyName","") == "规格":
  370. crawl_product_desc = structured.get("propertyValueName","")
  371. pic_path = raw.get("pic_path", "")
  372. raw_price = item_price_show
  373. if raw_price in (None, ""):
  374. price = Decimal("0.00")
  375. else:
  376. try:
  377. price = Decimal(str(raw_price)).quantize(Decimal("0.00"))
  378. except (InvalidOperation, ValueError):
  379. price = Decimal("0.00")
  380. scrape_date = time.strftime("%Y-%m-%d")
  381. update_time = time.strftime("%Y-%m-%d %H:%M:%S")
  382. snapshot_url = self._normalize_url(pic_path) if pic_path else ""
  383. # 字段与 yaofangwang_crawl 对齐;键顺序须与 commons.sql_data.RETRIEVE_SCRAPE_INSERT_COLUMNS 一致
  384. product = {
  385. "platform": self.platform,
  386. "item_id": item_id,
  387. "enterprise_id": self.company_id,
  388. "product_name": item_title,
  389. "spec": crawl_product_desc,
  390. "one_price": "",
  391. "detail_url": item_url,
  392. "shop_name": shop_name,
  393. "anonymous_store_name": "",
  394. "shop_url": shop_url,
  395. "city_name": "",
  396. "city_id": "",
  397. "province_name": "",
  398. "province_id": "",
  399. "shipment_city_name": city,
  400. "shipment_city_id": city_id,
  401. "shipment_province_name": province,
  402. "shipment_province_id": province_id,
  403. "area_info": area_str,
  404. "factory_name": "",
  405. "scrape_date": scrape_date,
  406. "price": price,
  407. "sales": sale_num,
  408. "stock_count": "",
  409. "snapshot_url": "",
  410. "approval_num": "",
  411. "produced_time": "",
  412. "deadline": "",
  413. "update_time": update_time,
  414. "insert_time": update_time,
  415. "number": 1,
  416. "product_brand": self.brand or "",
  417. "collect_task_id": self.collect_task_id,
  418. "task_id": self.task_id,
  419. "search_name": self.product,
  420. "company_name": "",
  421. "collect_config_info": json.dumps(
  422. {
  423. "sampling_cycle": self.sampling_cycle,
  424. "sampling_start_time": self.sampling_start_time,
  425. "sampling_end_time": self.sampling_end_time,
  426. }
  427. ),
  428. "account_id": self.account_id,
  429. "collect_region_id": self.collect_region_id,
  430. "collect_round": self.collect_round,
  431. "is_sold_out": 0
  432. }
  433. try:
  434. self.pipeline.storge_data(product)
  435. logger.info("%s", json.dumps(product, ensure_ascii=False, default=str))
  436. except Exception as e:
  437. logger.exception("写入数据库失败: %s", e)
  438. logger.info(
  439. "关键词 %s 第 %s 页爬取完成",
  440. keyword,
  441. page,
  442. )
  443. total_page = (
  444. (json_data or {}).get("data", {}).get("mainInfo", {}).get("totalPage")
  445. )
  446. try:
  447. total_page_int = int(total_page) if total_page is not None else 50
  448. except (TypeError, ValueError):
  449. total_page_int = 50
  450. self.report_data.update({'total_pages':total_page_int})
  451. if self.scheduler.end ==True:
  452. logger.info('心跳失败')
  453. self._error_report({'is_finished':0,'need_reassign':0,'current_page':page})
  454. break
  455. if page >= total_page_int:
  456. self._success_report({'is_finished':1,'need_reassign':0,'current_page':page})
  457. break
  458. if self.is_no_product > TAOBAO_NO_MATCH_THRESHOLD:
  459. self._success_report({'is_finished':1,'need_reassign':0,'current_page':page})
  460. break
  461. sleep_second = random.uniform(30, 60)
  462. logger.info("第 %s 页爬取完成,休息 %.1fs", page, sleep_second)
  463. self._success_report({'is_finished':0,'need_reassign':0,'current_page':page})
  464. time.sleep(sleep_second)
  465. def update_cookie(self):
  466. taobao_auto = TaobaoAutoCrawl(self.account_name, self.ip, self.product)
  467. if not taobao_auto.run():
  468. return False
  469. safe_name = self._sql_literal(self.account_name)
  470. sql_account = (
  471. f"select * from `retrieve_collect_equipment_account` where `username`='{safe_name}'"
  472. )
  473. account_list = self.db.select_data(sql_account)
  474. if not account_list:
  475. logger.error("账号 %s 未查询到 cookie 信息", self.account_name)
  476. return False
  477. cookie_str = account_list[0].get("cookie_str")
  478. if not cookie_str:
  479. logger.error("账号 %s cookie 为空", self.account_name)
  480. return False
  481. try:
  482. self.cookies = json.loads(cookie_str)
  483. except Exception as e:
  484. logger.error("账号 %s cookie 解析失败: %s", self.account_name, e)
  485. return False
  486. return True
  487. def get_account(self):
  488. sql_account = """
  489. SELECT * FROM `retrieve_collect_equipment_account` WHERE `id` = %s and `status` = 0
  490. """
  491. account_list = self.db.select_data(sql_account,self.account_id)
  492. if not account_list:
  493. return False
  494. account_dict = account_list[0]
  495. self.ip = account_dict.get("ip")
  496. cookie_str = account_dict.get("cookie_str")
  497. self.ip = account_dict.get("ip")
  498. self.account_name = account_dict.get("username")
  499. self.login_username = account_dict.get("phone", "")
  500. self.login_password = account_dict.get("password", "")
  501. self.cookie_stamp = account_dict.get("update_time")
  502. if self.ip:
  503. account_proxy = f"http://{self.ip}"
  504. self.proxies = {"http": account_proxy, "https": account_proxy}
  505. else:
  506. self.proxies = None
  507. need_refresh = (
  508. not cookie_str
  509. or int(time.time()) - int(self.cookie_stamp or 0) > COOKIE_MAX_AGE_SEC
  510. )
  511. print(account_dict)
  512. if need_refresh:
  513. if not self.update_cookie():
  514. return False
  515. else:
  516. try:
  517. self.cookies = json.loads(cookie_str)
  518. except Exception as e:
  519. logger.error("cookie 解析失败,尝试刷新: %s", e)
  520. if not self.update_cookie():
  521. return False
  522. logger.info("获取到账号: %s, ip: %s", self.account_name, self.ip)
  523. self.init_session()
  524. return True
  525. def run(self):
  526. if not self.get_account():
  527. logger.info("==================当前无账号可用==================")
  528. self.success = False
  529. return self.pipeline.crawl_count, self.success
  530. logger.info("获取到账号:%s,代理ip:%s", self.account_name, self.ip)
  531. self.get_search()
  532. logger.info(
  533. "任务id:%s, 任务状态已更新, 产品名称:%s, 爬取数据:%s条",
  534. self.task_id,
  535. self.product,
  536. self.pipeline.crawl_count,
  537. )
  538. return self.pipeline.crawl_count, self.success