3 次代码提交 d0c76a3864 ... 8604886d1e

作者 SHA1 备注 提交日期
  chenjunhao 8604886d1e chore: add __pycache__ and .idea to gitignore 6 天之前
  chenjunhao 24ce8174da Merge remote-tracking branch 'origin/master' 6 天之前
  chenjunhao d33a065ca2 xhs 6 天之前

+ 3 - 0
.gitignore

@@ -0,0 +1,3 @@
+__pycache__/
+.idea/
+*.pyc

+ 1929 - 0
xhs/area_info/city.json

@@ -0,0 +1,1929 @@
+[
+    {
+        "id": 1,
+        "name": "北京",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 2,
+                "name": "北京市",
+                "pid": 1
+            }
+        ]
+    },
+    {
+        "id": 368,
+        "name": "天津",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 369,
+                "name": "天津市",
+                "pid": 368
+            }
+        ]
+    },
+    {
+        "id": 685,
+        "name": "河北省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 686,
+                "name": "石家庄市",
+                "pid": 685
+            },
+            {
+                "id": 991,
+                "name": "唐山市",
+                "pid": 685
+            },
+            {
+                "id": 1261,
+                "name": "秦皇岛市",
+                "pid": 685
+            },
+            {
+                "id": 1373,
+                "name": "邯郸市",
+                "pid": 685
+            },
+            {
+                "id": 1640,
+                "name": "邢台市",
+                "pid": 685
+            },
+            {
+                "id": 1861,
+                "name": "保定市",
+                "pid": 685
+            },
+            {
+                "id": 2247,
+                "name": "张家口市",
+                "pid": 685
+            },
+            {
+                "id": 2513,
+                "name": "承德市",
+                "pid": 685
+            },
+            {
+                "id": 2745,
+                "name": "沧州市",
+                "pid": 685
+            },
+            {
+                "id": 2974,
+                "name": "廊坊市",
+                "pid": 685
+            },
+            {
+                "id": 3109,
+                "name": "衡水市",
+                "pid": 685
+            }
+        ]
+    },
+    {
+        "id": 3252,
+        "name": "山西省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 3253,
+                "name": "太原市",
+                "pid": 3252
+            },
+            {
+                "id": 3373,
+                "name": "大同市",
+                "pid": 3252
+            },
+            {
+                "id": 3512,
+                "name": "阳泉市",
+                "pid": 3252
+            },
+            {
+                "id": 3561,
+                "name": "长治市",
+                "pid": 3252
+            },
+            {
+                "id": 3723,
+                "name": "晋城市",
+                "pid": 3252
+            },
+            {
+                "id": 3807,
+                "name": "朔州市",
+                "pid": 3252
+            },
+            {
+                "id": 3887,
+                "name": "晋中市",
+                "pid": 3252
+            },
+            {
+                "id": 4029,
+                "name": "运城市",
+                "pid": 3252
+            },
+            {
+                "id": 4204,
+                "name": "忻州市",
+                "pid": 3252
+            },
+            {
+                "id": 4393,
+                "name": "临汾市",
+                "pid": 3252
+            },
+            {
+                "id": 4569,
+                "name": "吕梁市",
+                "pid": 3252
+            }
+        ]
+    },
+    {
+        "id": 4737,
+        "name": "内蒙古自治区",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 4738,
+                "name": "呼和浩特市",
+                "pid": 4737
+            },
+            {
+                "id": 4848,
+                "name": "包头市",
+                "pid": 4737
+            },
+            {
+                "id": 4950,
+                "name": "乌海市",
+                "pid": 4737
+            },
+            {
+                "id": 4976,
+                "name": "赤峰市",
+                "pid": 4737
+            },
+            {
+                "id": 5186,
+                "name": "通辽市",
+                "pid": 4737
+            },
+            {
+                "id": 5339,
+                "name": "鄂尔多斯市",
+                "pid": 4737
+            },
+            {
+                "id": 5437,
+                "name": "呼伦贝尔市",
+                "pid": 4737
+            },
+            {
+                "id": 5610,
+                "name": "巴彦淖尔市",
+                "pid": 4737
+            },
+            {
+                "id": 5707,
+                "name": "乌兰察布市",
+                "pid": 4737
+            },
+            {
+                "id": 5829,
+                "name": "兴安盟",
+                "pid": 4737
+            },
+            {
+                "id": 5926,
+                "name": "锡林郭勒盟",
+                "pid": 4737
+            },
+            {
+                "id": 6039,
+                "name": "阿拉善盟",
+                "pid": 4737
+            }
+        ]
+    },
+    {
+        "id": 6083,
+        "name": "辽宁省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 6084,
+                "name": "沈阳市",
+                "pid": 6083
+            },
+            {
+                "id": 6288,
+                "name": "大连市",
+                "pid": 6083
+            },
+            {
+                "id": 6451,
+                "name": "鞍山市",
+                "pid": 6083
+            },
+            {
+                "id": 6555,
+                "name": "抚顺市",
+                "pid": 6083
+            },
+            {
+                "id": 6635,
+                "name": "本溪市",
+                "pid": 6083
+            },
+            {
+                "id": 6691,
+                "name": "丹东市",
+                "pid": 6083
+            },
+            {
+                "id": 6787,
+                "name": "锦州市",
+                "pid": 6083
+            },
+            {
+                "id": 6899,
+                "name": "营口市",
+                "pid": 6083
+            },
+            {
+                "id": 6976,
+                "name": "阜新市",
+                "pid": 6083
+            },
+            {
+                "id": 7067,
+                "name": "辽阳市",
+                "pid": 6083
+            },
+            {
+                "id": 7125,
+                "name": "盘锦市",
+                "pid": 6083
+            },
+            {
+                "id": 7180,
+                "name": "铁岭市",
+                "pid": 6083
+            },
+            {
+                "id": 7294,
+                "name": "朝阳市",
+                "pid": 6083
+            },
+            {
+                "id": 7465,
+                "name": "葫芦岛市",
+                "pid": 6083
+            }
+        ]
+    },
+    {
+        "id": 7604,
+        "name": "吉林省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 7605,
+                "name": "长春市",
+                "pid": 7604
+            },
+            {
+                "id": 7852,
+                "name": "吉林市",
+                "pid": 7604
+            },
+            {
+                "id": 8015,
+                "name": "四平市",
+                "pid": 7604
+            },
+            {
+                "id": 8100,
+                "name": "辽源市",
+                "pid": 7604
+            },
+            {
+                "id": 8153,
+                "name": "通化市",
+                "pid": 7604
+            },
+            {
+                "id": 8270,
+                "name": "白山市",
+                "pid": 7604
+            },
+            {
+                "id": 8344,
+                "name": "松原市",
+                "pid": 7604
+            },
+            {
+                "id": 8498,
+                "name": "白城市",
+                "pid": 7604
+            },
+            {
+                "id": 8631,
+                "name": "延边朝鲜族自治州",
+                "pid": 7604
+            }
+        ]
+    },
+    {
+        "id": 8758,
+        "name": "黑龙江省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 8759,
+                "name": "哈尔滨市",
+                "pid": 8758
+            },
+            {
+                "id": 9106,
+                "name": "齐齐哈尔市",
+                "pid": 8758
+            },
+            {
+                "id": 9332,
+                "name": "鸡西市",
+                "pid": 8758
+            },
+            {
+                "id": 9437,
+                "name": "鹤岗市",
+                "pid": 8758
+            },
+            {
+                "id": 9515,
+                "name": "双鸭山市",
+                "pid": 8758
+            },
+            {
+                "id": 9634,
+                "name": "大庆市",
+                "pid": 8758
+            },
+            {
+                "id": 9763,
+                "name": "伊春市",
+                "pid": 8758
+            },
+            {
+                "id": 9824,
+                "name": "佳木斯市",
+                "pid": 8758
+            },
+            {
+                "id": 9973,
+                "name": "七台河市",
+                "pid": 8758
+            },
+            {
+                "id": 10022,
+                "name": "牡丹江市",
+                "pid": 8758
+            },
+            {
+                "id": 10125,
+                "name": "黑河市",
+                "pid": 8758
+            },
+            {
+                "id": 10292,
+                "name": "绥化市",
+                "pid": 8758
+            },
+            {
+                "id": 10550,
+                "name": "大兴安岭地区",
+                "pid": 8758
+            }
+        ]
+    },
+    {
+        "id": 10601,
+        "name": "上海",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 10602,
+                "name": "上海市",
+                "pid": 10601
+            }
+        ]
+    },
+    {
+        "id": 10853,
+        "name": "江苏省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 10854,
+                "name": "南京市",
+                "pid": 10853
+            },
+            {
+                "id": 11006,
+                "name": "无锡市",
+                "pid": 10853
+            },
+            {
+                "id": 11102,
+                "name": "徐州市",
+                "pid": 10853
+            },
+            {
+                "id": 11300,
+                "name": "常州市",
+                "pid": 10853
+            },
+            {
+                "id": 11374,
+                "name": "苏州市",
+                "pid": 10853
+            },
+            {
+                "id": 11503,
+                "name": "南通市",
+                "pid": 10853
+            },
+            {
+                "id": 11648,
+                "name": "连云港市",
+                "pid": 10853
+            },
+            {
+                "id": 11765,
+                "name": "淮安市",
+                "pid": 10853
+            },
+            {
+                "id": 11887,
+                "name": "盐城市",
+                "pid": 10853
+            },
+            {
+                "id": 12052,
+                "name": "扬州市",
+                "pid": 10853
+            },
+            {
+                "id": 12158,
+                "name": "镇江市",
+                "pid": 10853
+            },
+            {
+                "id": 12226,
+                "name": "泰州市",
+                "pid": 10853
+            },
+            {
+                "id": 12342,
+                "name": "宿迁市",
+                "pid": 10853
+            }
+        ]
+    },
+    {
+        "id": 12458,
+        "name": "浙江省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 12459,
+                "name": "杭州市",
+                "pid": 12458
+            },
+            {
+                "id": 12669,
+                "name": "宁波市",
+                "pid": 12458
+            },
+            {
+                "id": 12842,
+                "name": "温州市",
+                "pid": 12458
+            },
+            {
+                "id": 13046,
+                "name": "嘉兴市",
+                "pid": 12458
+            },
+            {
+                "id": 13126,
+                "name": "湖州市",
+                "pid": 12458
+            },
+            {
+                "id": 13204,
+                "name": "绍兴市",
+                "pid": 12458
+            },
+            {
+                "id": 13314,
+                "name": "金华市",
+                "pid": 12458
+            },
+            {
+                "id": 13470,
+                "name": "衢州市",
+                "pid": 12458
+            },
+            {
+                "id": 13577,
+                "name": "舟山市",
+                "pid": 12458
+            },
+            {
+                "id": 13618,
+                "name": "台州市",
+                "pid": 12458
+            },
+            {
+                "id": 13764,
+                "name": "丽水市",
+                "pid": 12458
+            }
+        ]
+    },
+    {
+        "id": 13947,
+        "name": "安徽省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 13948,
+                "name": "合肥市",
+                "pid": 13947
+            },
+            {
+                "id": 14121,
+                "name": "芜湖市",
+                "pid": 13947
+            },
+            {
+                "id": 14210,
+                "name": "蚌埠市",
+                "pid": 13947
+            },
+            {
+                "id": 14307,
+                "name": "淮南市",
+                "pid": 13947
+            },
+            {
+                "id": 14410,
+                "name": "马鞍山市",
+                "pid": 13947
+            },
+            {
+                "id": 14476,
+                "name": "淮北市",
+                "pid": 13947
+            },
+            {
+                "id": 14518,
+                "name": "铜陵市",
+                "pid": 13947
+            },
+            {
+                "id": 14583,
+                "name": "安庆市",
+                "pid": 13947
+            },
+            {
+                "id": 14763,
+                "name": "黄山市",
+                "pid": 13947
+            },
+            {
+                "id": 14880,
+                "name": "滁州市",
+                "pid": 13947
+            },
+            {
+                "id": 15015,
+                "name": "阜阳市",
+                "pid": 13947
+            },
+            {
+                "id": 15196,
+                "name": "宿州市",
+                "pid": 13947
+            },
+            {
+                "id": 15326,
+                "name": "六安市",
+                "pid": 13947
+            },
+            {
+                "id": 15480,
+                "name": "亳州市",
+                "pid": 13947
+            },
+            {
+                "id": 15581,
+                "name": "池州市",
+                "pid": 13947
+            },
+            {
+                "id": 15652,
+                "name": "宣城市",
+                "pid": 13947
+            }
+        ]
+    },
+    {
+        "id": 15768,
+        "name": "福建省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 15769,
+                "name": "福州市",
+                "pid": 15768
+            },
+            {
+                "id": 15969,
+                "name": "厦门市",
+                "pid": 15768
+            },
+            {
+                "id": 16030,
+                "name": "莆田市",
+                "pid": 15768
+            },
+            {
+                "id": 16093,
+                "name": "三明市",
+                "pid": 15768
+            },
+            {
+                "id": 16252,
+                "name": "泉州市",
+                "pid": 15768
+            },
+            {
+                "id": 16440,
+                "name": "漳州市",
+                "pid": 15768
+            },
+            {
+                "id": 16598,
+                "name": "南平市",
+                "pid": 15768
+            },
+            {
+                "id": 16751,
+                "name": "龙岩市",
+                "pid": 15768
+            },
+            {
+                "id": 16892,
+                "name": "宁德市",
+                "pid": 15768
+            }
+        ]
+    },
+    {
+        "id": 17032,
+        "name": "江西省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 17033,
+                "name": "南昌市",
+                "pid": 17032
+            },
+            {
+                "id": 17182,
+                "name": "景德镇市",
+                "pid": 17032
+            },
+            {
+                "id": 17246,
+                "name": "萍乡市",
+                "pid": 17032
+            },
+            {
+                "id": 17310,
+                "name": "九江市",
+                "pid": 17032
+            },
+            {
+                "id": 17551,
+                "name": "新余市",
+                "pid": 17032
+            },
+            {
+                "id": 17588,
+                "name": "鹰潭市",
+                "pid": 17032
+            },
+            {
+                "id": 17651,
+                "name": "赣州市",
+                "pid": 17032
+            },
+            {
+                "id": 17980,
+                "name": "吉安市",
+                "pid": 17032
+            },
+            {
+                "id": 18246,
+                "name": "宜春市",
+                "pid": 17032
+            },
+            {
+                "id": 18476,
+                "name": "抚州市",
+                "pid": 17032
+            },
+            {
+                "id": 18669,
+                "name": "上饶市",
+                "pid": 17032
+            }
+        ]
+    },
+    {
+        "id": 18911,
+        "name": "山东省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 18912,
+                "name": "济南市",
+                "pid": 18911
+            },
+            {
+                "id": 19090,
+                "name": "青岛市",
+                "pid": 18911
+            },
+            {
+                "id": 19242,
+                "name": "淄博市",
+                "pid": 18911
+            },
+            {
+                "id": 19339,
+                "name": "枣庄市",
+                "pid": 18911
+            },
+            {
+                "id": 19412,
+                "name": "东营市",
+                "pid": 18911
+            },
+            {
+                "id": 19472,
+                "name": "烟台市",
+                "pid": 18911
+            },
+            {
+                "id": 19645,
+                "name": "潍坊市",
+                "pid": 18911
+            },
+            {
+                "id": 19781,
+                "name": "济宁市",
+                "pid": 18911
+            },
+            {
+                "id": 19952,
+                "name": "泰安市",
+                "pid": 18911
+            },
+            {
+                "id": 20047,
+                "name": "威海市",
+                "pid": 18911
+            },
+            {
+                "id": 20128,
+                "name": "日照市",
+                "pid": 18911
+            },
+            {
+                "id": 20190,
+                "name": "临沂市",
+                "pid": 18911
+            },
+            {
+                "id": 20360,
+                "name": "德州市",
+                "pid": 18911
+            },
+            {
+                "id": 20508,
+                "name": "聊城市",
+                "pid": 18911
+            },
+            {
+                "id": 20653,
+                "name": "滨州市",
+                "pid": 18911
+            },
+            {
+                "id": 20753,
+                "name": "菏泽市",
+                "pid": 18911
+            }
+        ]
+    },
+    {
+        "id": 20932,
+        "name": "河南省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 20933,
+                "name": "郑州市",
+                "pid": 20932
+            },
+            {
+                "id": 21157,
+                "name": "开封市",
+                "pid": 20932
+            },
+            {
+                "id": 21291,
+                "name": "洛阳市",
+                "pid": 20932
+            },
+            {
+                "id": 21501,
+                "name": "平顶山市",
+                "pid": 20932
+            },
+            {
+                "id": 21662,
+                "name": "安阳市",
+                "pid": 20932
+            },
+            {
+                "id": 21810,
+                "name": "鹤壁市",
+                "pid": 20932
+            },
+            {
+                "id": 21865,
+                "name": "新乡市",
+                "pid": 20932
+            },
+            {
+                "id": 22051,
+                "name": "焦作市",
+                "pid": 20932
+            },
+            {
+                "id": 22175,
+                "name": "濮阳市",
+                "pid": 20932
+            },
+            {
+                "id": 22273,
+                "name": "许昌市",
+                "pid": 20932
+            },
+            {
+                "id": 22386,
+                "name": "漯河市",
+                "pid": 20932
+            },
+            {
+                "id": 22451,
+                "name": "三门峡市",
+                "pid": 20932
+            },
+            {
+                "id": 22540,
+                "name": "南阳市",
+                "pid": 20932
+            },
+            {
+                "id": 22812,
+                "name": "商丘市",
+                "pid": 20932
+            },
+            {
+                "id": 23023,
+                "name": "信阳市",
+                "pid": 20932
+            },
+            {
+                "id": 23259,
+                "name": "周口市",
+                "pid": 20932
+            },
+            {
+                "id": 23480,
+                "name": "驻马店市",
+                "pid": 20932
+            },
+            {
+                "id": 23700,
+                "name": "省直辖县级行政区划",
+                "pid": 20932
+            }
+        ]
+    },
+    {
+        "id": 23718,
+        "name": "湖北省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 23719,
+                "name": "武汉市",
+                "pid": 23718
+            },
+            {
+                "id": 23926,
+                "name": "黄石市",
+                "pid": 23718
+            },
+            {
+                "id": 23990,
+                "name": "十堰市",
+                "pid": 23718
+            },
+            {
+                "id": 24128,
+                "name": "宜昌市",
+                "pid": 23718
+            },
+            {
+                "id": 24255,
+                "name": "襄阳市",
+                "pid": 23718
+            },
+            {
+                "id": 24395,
+                "name": "鄂州市",
+                "pid": 23718
+            },
+            {
+                "id": 24427,
+                "name": "荆门市",
+                "pid": 23718
+            },
+            {
+                "id": 24517,
+                "name": "孝感市",
+                "pid": 23718
+            },
+            {
+                "id": 24653,
+                "name": "荆州市",
+                "pid": 23718
+            },
+            {
+                "id": 24792,
+                "name": "黄冈市",
+                "pid": 23718
+            },
+            {
+                "id": 24969,
+                "name": "咸宁市",
+                "pid": 23718
+            },
+            {
+                "id": 25065,
+                "name": "随州市",
+                "pid": 23718
+            },
+            {
+                "id": 25120,
+                "name": "恩施土家族苗族自治州",
+                "pid": 23718
+            },
+            {
+                "id": 25223,
+                "name": "省直辖县级行政区划",
+                "pid": 23718
+            }
+        ]
+    },
+    {
+        "id": 25313,
+        "name": "湖南省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 25314,
+                "name": "长沙市",
+                "pid": 25313
+            },
+            {
+                "id": 25496,
+                "name": "株洲市",
+                "pid": 25313
+            },
+            {
+                "id": 25618,
+                "name": "湘潭市",
+                "pid": 25313
+            },
+            {
+                "id": 25697,
+                "name": "衡阳市",
+                "pid": 25313
+            },
+            {
+                "id": 25903,
+                "name": "邵阳市",
+                "pid": 25313
+            },
+            {
+                "id": 26122,
+                "name": "岳阳市",
+                "pid": 25313
+            },
+            {
+                "id": 26271,
+                "name": "常德市",
+                "pid": 25313
+            },
+            {
+                "id": 26460,
+                "name": "张家界市",
+                "pid": 25313
+            },
+            {
+                "id": 26539,
+                "name": "益阳市",
+                "pid": 25313
+            },
+            {
+                "id": 26646,
+                "name": "郴州市",
+                "pid": 25313
+            },
+            {
+                "id": 26820,
+                "name": "永州市",
+                "pid": 25313
+            },
+            {
+                "id": 27029,
+                "name": "怀化市",
+                "pid": 25313
+            },
+            {
+                "id": 27250,
+                "name": "娄底市",
+                "pid": 25313
+            },
+            {
+                "id": 27345,
+                "name": "湘西土家族苗族自治州",
+                "pid": 25313
+            }
+        ]
+    },
+    {
+        "id": 27470,
+        "name": "广东省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 27471,
+                "name": "广州市",
+                "pid": 27470
+            },
+            {
+                "id": 27661,
+                "name": "韶关市",
+                "pid": 27470
+            },
+            {
+                "id": 27784,
+                "name": "深圳市",
+                "pid": 27470
+            },
+            {
+                "id": 27873,
+                "name": "珠海市",
+                "pid": 27470
+            },
+            {
+                "id": 27908,
+                "name": "汕头市",
+                "pid": 27470
+            },
+            {
+                "id": 27983,
+                "name": "佛山市",
+                "pid": 27470
+            },
+            {
+                "id": 28030,
+                "name": "江门市",
+                "pid": 27470
+            },
+            {
+                "id": 28120,
+                "name": "湛江市",
+                "pid": 27470
+            },
+            {
+                "id": 28255,
+                "name": "茂名市",
+                "pid": 27470
+            },
+            {
+                "id": 28388,
+                "name": "肇庆市",
+                "pid": 27470
+            },
+            {
+                "id": 28503,
+                "name": "惠州市",
+                "pid": 27470
+            },
+            {
+                "id": 28593,
+                "name": "梅州市",
+                "pid": 27470
+            },
+            {
+                "id": 28719,
+                "name": "汕尾市",
+                "pid": 27470
+            },
+            {
+                "id": 28787,
+                "name": "河源市",
+                "pid": 27470
+            },
+            {
+                "id": 28895,
+                "name": "阳江市",
+                "pid": 27470
+            },
+            {
+                "id": 28963,
+                "name": "清远市",
+                "pid": 27470
+            },
+            {
+                "id": 29064,
+                "name": "东莞市",
+                "pid": 27470
+            },
+            {
+                "id": 29102,
+                "name": "中山市",
+                "pid": 27470
+            },
+            {
+                "id": 29127,
+                "name": "潮州市",
+                "pid": 27470
+            },
+            {
+                "id": 29182,
+                "name": "揭阳市",
+                "pid": 27470
+            },
+            {
+                "id": 29296,
+                "name": "云浮市",
+                "pid": 27470
+            }
+        ]
+    },
+    {
+        "id": 29373,
+        "name": "广西壮族自治区",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 29374,
+                "name": "南宁市",
+                "pid": 29373
+            },
+            {
+                "id": 29525,
+                "name": "柳州市",
+                "pid": 29373
+            },
+            {
+                "id": 29654,
+                "name": "桂林市",
+                "pid": 29373
+            },
+            {
+                "id": 29820,
+                "name": "梧州市",
+                "pid": 29373
+            },
+            {
+                "id": 29894,
+                "name": "北海市",
+                "pid": 29373
+            },
+            {
+                "id": 29929,
+                "name": "防城港市",
+                "pid": 29373
+            },
+            {
+                "id": 29965,
+                "name": "钦州市",
+                "pid": 29373
+            },
+            {
+                "id": 30041,
+                "name": "贵港市",
+                "pid": 29373
+            },
+            {
+                "id": 30121,
+                "name": "玉林市",
+                "pid": 29373
+            },
+            {
+                "id": 30239,
+                "name": "百色市",
+                "pid": 29373
+            },
+            {
+                "id": 30389,
+                "name": "贺州市",
+                "pid": 29373
+            },
+            {
+                "id": 30456,
+                "name": "河池市",
+                "pid": 29373
+            },
+            {
+                "id": 30609,
+                "name": "来宾市",
+                "pid": 29373
+            },
+            {
+                "id": 30687,
+                "name": "崇左市",
+                "pid": 29373
+            }
+        ]
+    },
+    {
+        "id": 30783,
+        "name": "海南省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 30784,
+                "name": "海口市",
+                "pid": 30783
+            },
+            {
+                "id": 30835,
+                "name": "三亚市",
+                "pid": 30783
+            },
+            {
+                "id": 30844,
+                "name": "三沙市",
+                "pid": 30783
+            },
+            {
+                "id": 30853,
+                "name": "儋州市",
+                "pid": 30783
+            },
+            {
+                "id": 30873,
+                "name": "省直辖县级行政区划",
+                "pid": 30783
+            }
+        ]
+    },
+    {
+        "id": 31059,
+        "name": "重庆",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 31060,
+                "name": "重庆市",
+                "pid": 31059
+            },
+            {
+                "id": 31737,
+                "name": "自治县",
+                "pid": 31059
+            }
+        ]
+    },
+    {
+        "id": 32131,
+        "name": "四川省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 32132,
+                "name": "成都市",
+                "pid": 32131
+            },
+            {
+                "id": 32414,
+                "name": "自贡市",
+                "pid": 32131
+            },
+            {
+                "id": 32511,
+                "name": "攀枝花市",
+                "pid": 32131
+            },
+            {
+                "id": 32566,
+                "name": "泸州市",
+                "pid": 32131
+            },
+            {
+                "id": 32700,
+                "name": "德阳市",
+                "pid": 32131
+            },
+            {
+                "id": 32791,
+                "name": "绵阳市",
+                "pid": 32131
+            },
+            {
+                "id": 32968,
+                "name": "广元市",
+                "pid": 32131
+            },
+            {
+                "id": 33122,
+                "name": "遂宁市",
+                "pid": 32131
+            },
+            {
+                "id": 33223,
+                "name": "内江市",
+                "pid": 32131
+            },
+            {
+                "id": 33312,
+                "name": "乐山市",
+                "pid": 32131
+            },
+            {
+                "id": 33456,
+                "name": "南充市",
+                "pid": 32131
+            },
+            {
+                "id": 33708,
+                "name": "眉山市",
+                "pid": 32131
+            },
+            {
+                "id": 33795,
+                "name": "宜宾市",
+                "pid": 32131
+            },
+            {
+                "id": 33942,
+                "name": "广安市",
+                "pid": 32131
+            },
+            {
+                "id": 34073,
+                "name": "达州市",
+                "pid": 32131
+            },
+            {
+                "id": 34281,
+                "name": "雅安市",
+                "pid": 32131
+            },
+            {
+                "id": 34386,
+                "name": "巴中市",
+                "pid": 32131
+            },
+            {
+                "id": 34531,
+                "name": "资阳市",
+                "pid": 32131
+            },
+            {
+                "id": 34625,
+                "name": "阿坝藏族羌族自治州",
+                "pid": 32131
+            },
+            {
+                "id": 34817,
+                "name": "甘孜藏族自治州",
+                "pid": 32131
+            },
+            {
+                "id": 35125,
+                "name": "凉山彝族自治州",
+                "pid": 32131
+            }
+        ]
+    },
+    {
+        "id": 35447,
+        "name": "贵州省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 35448,
+                "name": "贵阳市",
+                "pid": 35447
+            },
+            {
+                "id": 35605,
+                "name": "六盘水市",
+                "pid": 35447
+            },
+            {
+                "id": 35702,
+                "name": "遵义市",
+                "pid": 35447
+            },
+            {
+                "id": 35971,
+                "name": "安顺市",
+                "pid": 35447
+            },
+            {
+                "id": 36070,
+                "name": "毕节市",
+                "pid": 35447
+            },
+            {
+                "id": 36358,
+                "name": "铜仁市",
+                "pid": 35447
+            },
+            {
+                "id": 36550,
+                "name": "黔西南布依族苗族自治州",
+                "pid": 35447
+            },
+            {
+                "id": 36699,
+                "name": "黔东南苗族侗族自治州",
+                "pid": 35447
+            },
+            {
+                "id": 36933,
+                "name": "黔南布依族苗族自治州",
+                "pid": 35447
+            }
+        ]
+    },
+    {
+        "id": 37055,
+        "name": "云南省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 37056,
+                "name": "昆明市",
+                "pid": 37055
+            },
+            {
+                "id": 37221,
+                "name": "曲靖市",
+                "pid": 37055
+            },
+            {
+                "id": 37368,
+                "name": "玉溪市",
+                "pid": 37055
+            },
+            {
+                "id": 37454,
+                "name": "保山市",
+                "pid": 37055
+            },
+            {
+                "id": 37536,
+                "name": "昭通市",
+                "pid": 37055
+            },
+            {
+                "id": 37698,
+                "name": "丽江市",
+                "pid": 37055
+            },
+            {
+                "id": 37770,
+                "name": "普洱市",
+                "pid": 37055
+            },
+            {
+                "id": 37884,
+                "name": "临沧市",
+                "pid": 37055
+            },
+            {
+                "id": 37977,
+                "name": "楚雄彝族自治州",
+                "pid": 37055
+            },
+            {
+                "id": 38091,
+                "name": "红河哈尼族彝族自治州",
+                "pid": 37055
+            },
+            {
+                "id": 38244,
+                "name": "文山壮族苗族自治州",
+                "pid": 37055
+            },
+            {
+                "id": 38359,
+                "name": "西双版纳傣族自治州",
+                "pid": 37055
+            },
+            {
+                "id": 38405,
+                "name": "大理白族自治州",
+                "pid": 37055
+            },
+            {
+                "id": 38530,
+                "name": "德宏傣族景颇族自治州",
+                "pid": 37055
+            },
+            {
+                "id": 38592,
+                "name": "怒江傈僳族自治州",
+                "pid": 37055
+            },
+            {
+                "id": 38628,
+                "name": "迪庆藏族自治州",
+                "pid": 37055
+            }
+        ]
+    },
+    {
+        "id": 38662,
+        "name": "西藏自治区",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 38663,
+                "name": "拉萨市",
+                "pid": 38662
+            },
+            {
+                "id": 38745,
+                "name": "日喀则市",
+                "pid": 38662
+            },
+            {
+                "id": 38970,
+                "name": "昌都市",
+                "pid": 38662
+            },
+            {
+                "id": 39120,
+                "name": "林芝市",
+                "pid": 38662
+            },
+            {
+                "id": 39184,
+                "name": "山南市",
+                "pid": 38662
+            },
+            {
+                "id": 39282,
+                "name": "那曲市",
+                "pid": 38662
+            },
+            {
+                "id": 39408,
+                "name": "阿里地区",
+                "pid": 38662
+            }
+        ]
+    },
+    {
+        "id": 39453,
+        "name": "陕西省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 39454,
+                "name": "西安市",
+                "pid": 39453
+            },
+            {
+                "id": 39640,
+                "name": "铜川市",
+                "pid": 39453
+            },
+            {
+                "id": 39683,
+                "name": "宝鸡市",
+                "pid": 39453
+            },
+            {
+                "id": 39816,
+                "name": "咸阳市",
+                "pid": 39453
+            },
+            {
+                "id": 39973,
+                "name": "渭南市",
+                "pid": 39453
+            },
+            {
+                "id": 40121,
+                "name": "延安市",
+                "pid": 39453
+            },
+            {
+                "id": 40252,
+                "name": "汉中市",
+                "pid": 39453
+            },
+            {
+                "id": 40441,
+                "name": "榆林市",
+                "pid": 39453
+            },
+            {
+                "id": 40638,
+                "name": "安康市",
+                "pid": 39453
+            },
+            {
+                "id": 40788,
+                "name": "商洛市",
+                "pid": 39453
+            }
+        ]
+    },
+    {
+        "id": 40906,
+        "name": "甘肃省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 40907,
+                "name": "兰州市",
+                "pid": 40906
+            },
+            {
+                "id": 41033,
+                "name": "嘉峪关市",
+                "pid": 40906
+            },
+            {
+                "id": 41040,
+                "name": "金昌市",
+                "pid": 40906
+            },
+            {
+                "id": 41061,
+                "name": "白银市",
+                "pid": 40906
+            },
+            {
+                "id": 41145,
+                "name": "天水市",
+                "pid": 40906
+            },
+            {
+                "id": 41283,
+                "name": "武威市",
+                "pid": 40906
+            },
+            {
+                "id": 41396,
+                "name": "张掖市",
+                "pid": 40906
+            },
+            {
+                "id": 41481,
+                "name": "平凉市",
+                "pid": 40906
+            },
+            {
+                "id": 41602,
+                "name": "酒泉市",
+                "pid": 40906
+            },
+            {
+                "id": 41694,
+                "name": "庆阳市",
+                "pid": 40906
+            },
+            {
+                "id": 41822,
+                "name": "定西市",
+                "pid": 40906
+            },
+            {
+                "id": 41952,
+                "name": "陇南市",
+                "pid": 40906
+            },
+            {
+                "id": 42161,
+                "name": "临夏回族自治州",
+                "pid": 40906
+            },
+            {
+                "id": 42301,
+                "name": "甘南藏族自治州",
+                "pid": 40906
+            }
+        ]
+    },
+    {
+        "id": 42412,
+        "name": "青海省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 42413,
+                "name": "西宁市",
+                "pid": 42412
+            },
+            {
+                "id": 42501,
+                "name": "海东市",
+                "pid": 42412
+            },
+            {
+                "id": 42606,
+                "name": "海北藏族自治州",
+                "pid": 42412
+            },
+            {
+                "id": 42643,
+                "name": "黄南藏族自治州",
+                "pid": 42412
+            },
+            {
+                "id": 42681,
+                "name": "海南藏族自治州",
+                "pid": 42412
+            },
+            {
+                "id": 42727,
+                "name": "果洛藏族自治州",
+                "pid": 42412
+            },
+            {
+                "id": 42778,
+                "name": "玉树藏族自治州",
+                "pid": 42412
+            },
+            {
+                "id": 42834,
+                "name": "海西蒙古族藏族自治州",
+                "pid": 42412
+            }
+        ]
+    },
+    {
+        "id": 42888,
+        "name": "宁夏回族自治区",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 42889,
+                "name": "银川市",
+                "pid": 42888
+            },
+            {
+                "id": 42958,
+                "name": "石嘴山市",
+                "pid": 42888
+            },
+            {
+                "id": 42998,
+                "name": "吴忠市",
+                "pid": 42888
+            },
+            {
+                "id": 43054,
+                "name": "固原市",
+                "pid": 42888
+            },
+            {
+                "id": 43125,
+                "name": "中卫市",
+                "pid": 42888
+            }
+        ]
+    },
+    {
+        "id": 43175,
+        "name": "新疆维吾尔自治区",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 43176,
+                "name": "乌鲁木齐市",
+                "pid": 43175
+            },
+            {
+                "id": 43315,
+                "name": "克拉玛依市",
+                "pid": 43175
+            },
+            {
+                "id": 43339,
+                "name": "吐鲁番市",
+                "pid": 43175
+            },
+            {
+                "id": 43378,
+                "name": "哈密市",
+                "pid": 43175
+            },
+            {
+                "id": 43440,
+                "name": "昌吉回族自治州",
+                "pid": 43175
+            },
+            {
+                "id": 43554,
+                "name": "博尔塔拉蒙古自治州",
+                "pid": 43175
+            },
+            {
+                "id": 43593,
+                "name": "巴音郭楞蒙古自治州",
+                "pid": 43175
+            },
+            {
+                "id": 43712,
+                "name": "阿克苏地区",
+                "pid": 43175
+            },
+            {
+                "id": 43836,
+                "name": "克孜勒苏柯尔克孜自治州",
+                "pid": 43175
+            },
+            {
+                "id": 43887,
+                "name": "喀什地区",
+                "pid": 43175
+            },
+            {
+                "id": 44109,
+                "name": "和田地区",
+                "pid": 43175
+            },
+            {
+                "id": 44224,
+                "name": "伊犁哈萨克自治州",
+                "pid": 43175
+            },
+            {
+                "id": 44386,
+                "name": "塔城地区",
+                "pid": 43175
+            },
+            {
+                "id": 44514,
+                "name": "阿勒泰地区",
+                "pid": 43175
+            },
+            {
+                "id": 44592,
+                "name": "自治区直辖县级行政区划",
+                "pid": 43175
+            }
+        ]
+    }
+]

+ 34 - 0
xhs/area_info/city_name_to_id.py

@@ -0,0 +1,34 @@
+import os
+from pathlib import Path
+from area.area import AreaService
+
+# 导出供外部使用
+_DEFAULT_PATH = Path(__file__).with_name("city.json")
+_area_service = AreaService(str(_DEFAULT_PATH))
+
+def get_city(city_str):
+    """可以通过市获取省份(重构版,底层使用 Trie 树解析)"""
+    city_id = 0
+    province_id = 0
+    city = ""
+    province = ""
+    
+    if not city_str:
+        return city_id, province_id, city, province
+
+    match = _area_service.search_area(city_str)
+    if match:
+        city_id = match.city_id
+        province_id = match.province_id
+        city = match.city
+        province = match.province
+
+    return city_id, province_id, city, province
+
+def get_city_2(city_str):
+    """兼容旧版本的调用"""
+    return get_city(city_str)
+
+if __name__ == "__main__":
+    city_id, province_id, city, province = get_city("玉林")
+    print(city_id, province_id, city, province)

+ 30 - 0
xhs/area_info/city_name_to_id_2.py

@@ -0,0 +1,30 @@
+import os
+from pathlib import Path
+from area.area import AreaService
+
+# 导出供外部使用
+_DEFAULT_PATH = Path(__file__).with_name("city.json")
+_area_service = AreaService(str(_DEFAULT_PATH))
+
+def get_city(city_str):
+    """可以通过市获取省份(重构版,底层使用 Trie 树解析)"""
+    city_id = 0
+    province_id = 0
+    city = ""
+    province = ""
+    
+    if not city_str:
+        return city_id, province_id, city, province
+
+    match = _area_service.search_area(city_str)
+    if match:
+        city_id = match.city_id
+        province_id = match.province_id
+        city = match.city
+        province = match.province
+
+    return city_id, province_id, city, province
+
+if __name__ == "__main__":
+    city_id, province_id, city, province = get_city("玉林")
+    print(city_id, province_id, city, province)

+ 64 - 0
xhs/commons/Logger.py

@@ -0,0 +1,64 @@
+import logging
+from logging.handlers import TimedRotatingFileHandler
+import os
+from datetime import datetime
+
+LOG_FORMAT = "%(asctime)s [%(levelname)s] [%(threadName)s] %(message)s"
+
+
+def _build_logger(name: str, log_file: str) -> logging.Logger:
+    logger_obj = logging.getLogger(name)
+    if logger_obj.handlers:
+        return logger_obj
+
+    logger_obj.setLevel(logging.DEBUG)
+    logger_obj.propagate = False
+
+    console_handler = logging.StreamHandler()
+    console_handler.setLevel(logging.INFO)
+
+    file_handler = logging.FileHandler(log_file, encoding="utf-8")
+    file_handler.setLevel(logging.DEBUG)
+
+    formatter = logging.Formatter(LOG_FORMAT)
+    console_handler.setFormatter(formatter)
+    file_handler.setFormatter(formatter)
+
+    logger_obj.addHandler(console_handler)
+    logger_obj.addHandler(file_handler)
+    return logger_obj
+
+
+def get_spider_logger(spider_name: str) -> logging.Logger:
+    base_dir = os.path.join("logs", spider_name)
+    os.makedirs(base_dir, exist_ok=True)
+    time_str = datetime.now().strftime("%Y-%m-%d_%H%M")
+    log_file = os.path.join(base_dir, f"{spider_name}_{time_str}.log")
+    return _build_logger(f"spider.{spider_name}", log_file)
+
+import sys
+script_name = os.path.basename(sys.argv[0]).lower()
+if 'jd' in script_name:
+    prefix = 'jd'
+elif 'taobao' in script_name:
+    prefix = 'taobao'
+elif 'pdd' in script_name:
+    prefix = 'pdd'
+elif 'xhs' in script_name:
+    prefix = 'xhs'
+else:
+    prefix = 'app'
+
+# 获取当前时间字符串精确到分
+time_str = datetime.now().strftime("%Y-%m-%d_%H%M")
+legacy_dir = "logs"
+os.makedirs(legacy_dir, exist_ok=True)
+legacy_file = os.path.join(legacy_dir, f"{prefix}_{time_str}.log")
+logger = _build_logger("MyLogger", legacy_file)
+
+# 测试
+# logger.debug("调试信息")
+# logger.info("普通提示信息")
+# logger.warning("警告信息")
+# logger.error("错误信息")
+# logger.critical("严重错误信息")

+ 1929 - 0
xhs/commons/city.json

@@ -0,0 +1,1929 @@
+[
+    {
+        "id": 1,
+        "name": "北京",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 2,
+                "name": "北京市",
+                "pid": 1
+            }
+        ]
+    },
+    {
+        "id": 368,
+        "name": "天津",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 369,
+                "name": "天津市",
+                "pid": 368
+            }
+        ]
+    },
+    {
+        "id": 685,
+        "name": "河北省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 686,
+                "name": "石家庄市",
+                "pid": 685
+            },
+            {
+                "id": 991,
+                "name": "唐山市",
+                "pid": 685
+            },
+            {
+                "id": 1261,
+                "name": "秦皇岛市",
+                "pid": 685
+            },
+            {
+                "id": 1373,
+                "name": "邯郸市",
+                "pid": 685
+            },
+            {
+                "id": 1640,
+                "name": "邢台市",
+                "pid": 685
+            },
+            {
+                "id": 1861,
+                "name": "保定市",
+                "pid": 685
+            },
+            {
+                "id": 2247,
+                "name": "张家口市",
+                "pid": 685
+            },
+            {
+                "id": 2513,
+                "name": "承德市",
+                "pid": 685
+            },
+            {
+                "id": 2745,
+                "name": "沧州市",
+                "pid": 685
+            },
+            {
+                "id": 2974,
+                "name": "廊坊市",
+                "pid": 685
+            },
+            {
+                "id": 3109,
+                "name": "衡水市",
+                "pid": 685
+            }
+        ]
+    },
+    {
+        "id": 3252,
+        "name": "山西省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 3253,
+                "name": "太原市",
+                "pid": 3252
+            },
+            {
+                "id": 3373,
+                "name": "大同市",
+                "pid": 3252
+            },
+            {
+                "id": 3512,
+                "name": "阳泉市",
+                "pid": 3252
+            },
+            {
+                "id": 3561,
+                "name": "长治市",
+                "pid": 3252
+            },
+            {
+                "id": 3723,
+                "name": "晋城市",
+                "pid": 3252
+            },
+            {
+                "id": 3807,
+                "name": "朔州市",
+                "pid": 3252
+            },
+            {
+                "id": 3887,
+                "name": "晋中市",
+                "pid": 3252
+            },
+            {
+                "id": 4029,
+                "name": "运城市",
+                "pid": 3252
+            },
+            {
+                "id": 4204,
+                "name": "忻州市",
+                "pid": 3252
+            },
+            {
+                "id": 4393,
+                "name": "临汾市",
+                "pid": 3252
+            },
+            {
+                "id": 4569,
+                "name": "吕梁市",
+                "pid": 3252
+            }
+        ]
+    },
+    {
+        "id": 4737,
+        "name": "内蒙古自治区",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 4738,
+                "name": "呼和浩特市",
+                "pid": 4737
+            },
+            {
+                "id": 4848,
+                "name": "包头市",
+                "pid": 4737
+            },
+            {
+                "id": 4950,
+                "name": "乌海市",
+                "pid": 4737
+            },
+            {
+                "id": 4976,
+                "name": "赤峰市",
+                "pid": 4737
+            },
+            {
+                "id": 5186,
+                "name": "通辽市",
+                "pid": 4737
+            },
+            {
+                "id": 5339,
+                "name": "鄂尔多斯市",
+                "pid": 4737
+            },
+            {
+                "id": 5437,
+                "name": "呼伦贝尔市",
+                "pid": 4737
+            },
+            {
+                "id": 5610,
+                "name": "巴彦淖尔市",
+                "pid": 4737
+            },
+            {
+                "id": 5707,
+                "name": "乌兰察布市",
+                "pid": 4737
+            },
+            {
+                "id": 5829,
+                "name": "兴安盟",
+                "pid": 4737
+            },
+            {
+                "id": 5926,
+                "name": "锡林郭勒盟",
+                "pid": 4737
+            },
+            {
+                "id": 6039,
+                "name": "阿拉善盟",
+                "pid": 4737
+            }
+        ]
+    },
+    {
+        "id": 6083,
+        "name": "辽宁省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 6084,
+                "name": "沈阳市",
+                "pid": 6083
+            },
+            {
+                "id": 6288,
+                "name": "大连市",
+                "pid": 6083
+            },
+            {
+                "id": 6451,
+                "name": "鞍山市",
+                "pid": 6083
+            },
+            {
+                "id": 6555,
+                "name": "抚顺市",
+                "pid": 6083
+            },
+            {
+                "id": 6635,
+                "name": "本溪市",
+                "pid": 6083
+            },
+            {
+                "id": 6691,
+                "name": "丹东市",
+                "pid": 6083
+            },
+            {
+                "id": 6787,
+                "name": "锦州市",
+                "pid": 6083
+            },
+            {
+                "id": 6899,
+                "name": "营口市",
+                "pid": 6083
+            },
+            {
+                "id": 6976,
+                "name": "阜新市",
+                "pid": 6083
+            },
+            {
+                "id": 7067,
+                "name": "辽阳市",
+                "pid": 6083
+            },
+            {
+                "id": 7125,
+                "name": "盘锦市",
+                "pid": 6083
+            },
+            {
+                "id": 7180,
+                "name": "铁岭市",
+                "pid": 6083
+            },
+            {
+                "id": 7294,
+                "name": "朝阳市",
+                "pid": 6083
+            },
+            {
+                "id": 7465,
+                "name": "葫芦岛市",
+                "pid": 6083
+            }
+        ]
+    },
+    {
+        "id": 7604,
+        "name": "吉林省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 7605,
+                "name": "长春市",
+                "pid": 7604
+            },
+            {
+                "id": 7852,
+                "name": "吉林市",
+                "pid": 7604
+            },
+            {
+                "id": 8015,
+                "name": "四平市",
+                "pid": 7604
+            },
+            {
+                "id": 8100,
+                "name": "辽源市",
+                "pid": 7604
+            },
+            {
+                "id": 8153,
+                "name": "通化市",
+                "pid": 7604
+            },
+            {
+                "id": 8270,
+                "name": "白山市",
+                "pid": 7604
+            },
+            {
+                "id": 8344,
+                "name": "松原市",
+                "pid": 7604
+            },
+            {
+                "id": 8498,
+                "name": "白城市",
+                "pid": 7604
+            },
+            {
+                "id": 8631,
+                "name": "延边朝鲜族自治州",
+                "pid": 7604
+            }
+        ]
+    },
+    {
+        "id": 8758,
+        "name": "黑龙江省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 8759,
+                "name": "哈尔滨市",
+                "pid": 8758
+            },
+            {
+                "id": 9106,
+                "name": "齐齐哈尔市",
+                "pid": 8758
+            },
+            {
+                "id": 9332,
+                "name": "鸡西市",
+                "pid": 8758
+            },
+            {
+                "id": 9437,
+                "name": "鹤岗市",
+                "pid": 8758
+            },
+            {
+                "id": 9515,
+                "name": "双鸭山市",
+                "pid": 8758
+            },
+            {
+                "id": 9634,
+                "name": "大庆市",
+                "pid": 8758
+            },
+            {
+                "id": 9763,
+                "name": "伊春市",
+                "pid": 8758
+            },
+            {
+                "id": 9824,
+                "name": "佳木斯市",
+                "pid": 8758
+            },
+            {
+                "id": 9973,
+                "name": "七台河市",
+                "pid": 8758
+            },
+            {
+                "id": 10022,
+                "name": "牡丹江市",
+                "pid": 8758
+            },
+            {
+                "id": 10125,
+                "name": "黑河市",
+                "pid": 8758
+            },
+            {
+                "id": 10292,
+                "name": "绥化市",
+                "pid": 8758
+            },
+            {
+                "id": 10550,
+                "name": "大兴安岭地区",
+                "pid": 8758
+            }
+        ]
+    },
+    {
+        "id": 10601,
+        "name": "上海",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 10602,
+                "name": "上海市",
+                "pid": 10601
+            }
+        ]
+    },
+    {
+        "id": 10853,
+        "name": "江苏省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 10854,
+                "name": "南京市",
+                "pid": 10853
+            },
+            {
+                "id": 11006,
+                "name": "无锡市",
+                "pid": 10853
+            },
+            {
+                "id": 11102,
+                "name": "徐州市",
+                "pid": 10853
+            },
+            {
+                "id": 11300,
+                "name": "常州市",
+                "pid": 10853
+            },
+            {
+                "id": 11374,
+                "name": "苏州市",
+                "pid": 10853
+            },
+            {
+                "id": 11503,
+                "name": "南通市",
+                "pid": 10853
+            },
+            {
+                "id": 11648,
+                "name": "连云港市",
+                "pid": 10853
+            },
+            {
+                "id": 11765,
+                "name": "淮安市",
+                "pid": 10853
+            },
+            {
+                "id": 11887,
+                "name": "盐城市",
+                "pid": 10853
+            },
+            {
+                "id": 12052,
+                "name": "扬州市",
+                "pid": 10853
+            },
+            {
+                "id": 12158,
+                "name": "镇江市",
+                "pid": 10853
+            },
+            {
+                "id": 12226,
+                "name": "泰州市",
+                "pid": 10853
+            },
+            {
+                "id": 12342,
+                "name": "宿迁市",
+                "pid": 10853
+            }
+        ]
+    },
+    {
+        "id": 12458,
+        "name": "浙江省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 12459,
+                "name": "杭州市",
+                "pid": 12458
+            },
+            {
+                "id": 12669,
+                "name": "宁波市",
+                "pid": 12458
+            },
+            {
+                "id": 12842,
+                "name": "温州市",
+                "pid": 12458
+            },
+            {
+                "id": 13046,
+                "name": "嘉兴市",
+                "pid": 12458
+            },
+            {
+                "id": 13126,
+                "name": "湖州市",
+                "pid": 12458
+            },
+            {
+                "id": 13204,
+                "name": "绍兴市",
+                "pid": 12458
+            },
+            {
+                "id": 13314,
+                "name": "金华市",
+                "pid": 12458
+            },
+            {
+                "id": 13470,
+                "name": "衢州市",
+                "pid": 12458
+            },
+            {
+                "id": 13577,
+                "name": "舟山市",
+                "pid": 12458
+            },
+            {
+                "id": 13618,
+                "name": "台州市",
+                "pid": 12458
+            },
+            {
+                "id": 13764,
+                "name": "丽水市",
+                "pid": 12458
+            }
+        ]
+    },
+    {
+        "id": 13947,
+        "name": "安徽省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 13948,
+                "name": "合肥市",
+                "pid": 13947
+            },
+            {
+                "id": 14121,
+                "name": "芜湖市",
+                "pid": 13947
+            },
+            {
+                "id": 14210,
+                "name": "蚌埠市",
+                "pid": 13947
+            },
+            {
+                "id": 14307,
+                "name": "淮南市",
+                "pid": 13947
+            },
+            {
+                "id": 14410,
+                "name": "马鞍山市",
+                "pid": 13947
+            },
+            {
+                "id": 14476,
+                "name": "淮北市",
+                "pid": 13947
+            },
+            {
+                "id": 14518,
+                "name": "铜陵市",
+                "pid": 13947
+            },
+            {
+                "id": 14583,
+                "name": "安庆市",
+                "pid": 13947
+            },
+            {
+                "id": 14763,
+                "name": "黄山市",
+                "pid": 13947
+            },
+            {
+                "id": 14880,
+                "name": "滁州市",
+                "pid": 13947
+            },
+            {
+                "id": 15015,
+                "name": "阜阳市",
+                "pid": 13947
+            },
+            {
+                "id": 15196,
+                "name": "宿州市",
+                "pid": 13947
+            },
+            {
+                "id": 15326,
+                "name": "六安市",
+                "pid": 13947
+            },
+            {
+                "id": 15480,
+                "name": "亳州市",
+                "pid": 13947
+            },
+            {
+                "id": 15581,
+                "name": "池州市",
+                "pid": 13947
+            },
+            {
+                "id": 15652,
+                "name": "宣城市",
+                "pid": 13947
+            }
+        ]
+    },
+    {
+        "id": 15768,
+        "name": "福建省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 15769,
+                "name": "福州市",
+                "pid": 15768
+            },
+            {
+                "id": 15969,
+                "name": "厦门市",
+                "pid": 15768
+            },
+            {
+                "id": 16030,
+                "name": "莆田市",
+                "pid": 15768
+            },
+            {
+                "id": 16093,
+                "name": "三明市",
+                "pid": 15768
+            },
+            {
+                "id": 16252,
+                "name": "泉州市",
+                "pid": 15768
+            },
+            {
+                "id": 16440,
+                "name": "漳州市",
+                "pid": 15768
+            },
+            {
+                "id": 16598,
+                "name": "南平市",
+                "pid": 15768
+            },
+            {
+                "id": 16751,
+                "name": "龙岩市",
+                "pid": 15768
+            },
+            {
+                "id": 16892,
+                "name": "宁德市",
+                "pid": 15768
+            }
+        ]
+    },
+    {
+        "id": 17032,
+        "name": "江西省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 17033,
+                "name": "南昌市",
+                "pid": 17032
+            },
+            {
+                "id": 17182,
+                "name": "景德镇市",
+                "pid": 17032
+            },
+            {
+                "id": 17246,
+                "name": "萍乡市",
+                "pid": 17032
+            },
+            {
+                "id": 17310,
+                "name": "九江市",
+                "pid": 17032
+            },
+            {
+                "id": 17551,
+                "name": "新余市",
+                "pid": 17032
+            },
+            {
+                "id": 17588,
+                "name": "鹰潭市",
+                "pid": 17032
+            },
+            {
+                "id": 17651,
+                "name": "赣州市",
+                "pid": 17032
+            },
+            {
+                "id": 17980,
+                "name": "吉安市",
+                "pid": 17032
+            },
+            {
+                "id": 18246,
+                "name": "宜春市",
+                "pid": 17032
+            },
+            {
+                "id": 18476,
+                "name": "抚州市",
+                "pid": 17032
+            },
+            {
+                "id": 18669,
+                "name": "上饶市",
+                "pid": 17032
+            }
+        ]
+    },
+    {
+        "id": 18911,
+        "name": "山东省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 18912,
+                "name": "济南市",
+                "pid": 18911
+            },
+            {
+                "id": 19090,
+                "name": "青岛市",
+                "pid": 18911
+            },
+            {
+                "id": 19242,
+                "name": "淄博市",
+                "pid": 18911
+            },
+            {
+                "id": 19339,
+                "name": "枣庄市",
+                "pid": 18911
+            },
+            {
+                "id": 19412,
+                "name": "东营市",
+                "pid": 18911
+            },
+            {
+                "id": 19472,
+                "name": "烟台市",
+                "pid": 18911
+            },
+            {
+                "id": 19645,
+                "name": "潍坊市",
+                "pid": 18911
+            },
+            {
+                "id": 19781,
+                "name": "济宁市",
+                "pid": 18911
+            },
+            {
+                "id": 19952,
+                "name": "泰安市",
+                "pid": 18911
+            },
+            {
+                "id": 20047,
+                "name": "威海市",
+                "pid": 18911
+            },
+            {
+                "id": 20128,
+                "name": "日照市",
+                "pid": 18911
+            },
+            {
+                "id": 20190,
+                "name": "临沂市",
+                "pid": 18911
+            },
+            {
+                "id": 20360,
+                "name": "德州市",
+                "pid": 18911
+            },
+            {
+                "id": 20508,
+                "name": "聊城市",
+                "pid": 18911
+            },
+            {
+                "id": 20653,
+                "name": "滨州市",
+                "pid": 18911
+            },
+            {
+                "id": 20753,
+                "name": "菏泽市",
+                "pid": 18911
+            }
+        ]
+    },
+    {
+        "id": 20932,
+        "name": "河南省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 20933,
+                "name": "郑州市",
+                "pid": 20932
+            },
+            {
+                "id": 21157,
+                "name": "开封市",
+                "pid": 20932
+            },
+            {
+                "id": 21291,
+                "name": "洛阳市",
+                "pid": 20932
+            },
+            {
+                "id": 21501,
+                "name": "平顶山市",
+                "pid": 20932
+            },
+            {
+                "id": 21662,
+                "name": "安阳市",
+                "pid": 20932
+            },
+            {
+                "id": 21810,
+                "name": "鹤壁市",
+                "pid": 20932
+            },
+            {
+                "id": 21865,
+                "name": "新乡市",
+                "pid": 20932
+            },
+            {
+                "id": 22051,
+                "name": "焦作市",
+                "pid": 20932
+            },
+            {
+                "id": 22175,
+                "name": "濮阳市",
+                "pid": 20932
+            },
+            {
+                "id": 22273,
+                "name": "许昌市",
+                "pid": 20932
+            },
+            {
+                "id": 22386,
+                "name": "漯河市",
+                "pid": 20932
+            },
+            {
+                "id": 22451,
+                "name": "三门峡市",
+                "pid": 20932
+            },
+            {
+                "id": 22540,
+                "name": "南阳市",
+                "pid": 20932
+            },
+            {
+                "id": 22812,
+                "name": "商丘市",
+                "pid": 20932
+            },
+            {
+                "id": 23023,
+                "name": "信阳市",
+                "pid": 20932
+            },
+            {
+                "id": 23259,
+                "name": "周口市",
+                "pid": 20932
+            },
+            {
+                "id": 23480,
+                "name": "驻马店市",
+                "pid": 20932
+            },
+            {
+                "id": 23700,
+                "name": "省直辖县级行政区划",
+                "pid": 20932
+            }
+        ]
+    },
+    {
+        "id": 23718,
+        "name": "湖北省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 23719,
+                "name": "武汉市",
+                "pid": 23718
+            },
+            {
+                "id": 23926,
+                "name": "黄石市",
+                "pid": 23718
+            },
+            {
+                "id": 23990,
+                "name": "十堰市",
+                "pid": 23718
+            },
+            {
+                "id": 24128,
+                "name": "宜昌市",
+                "pid": 23718
+            },
+            {
+                "id": 24255,
+                "name": "襄阳市",
+                "pid": 23718
+            },
+            {
+                "id": 24395,
+                "name": "鄂州市",
+                "pid": 23718
+            },
+            {
+                "id": 24427,
+                "name": "荆门市",
+                "pid": 23718
+            },
+            {
+                "id": 24517,
+                "name": "孝感市",
+                "pid": 23718
+            },
+            {
+                "id": 24653,
+                "name": "荆州市",
+                "pid": 23718
+            },
+            {
+                "id": 24792,
+                "name": "黄冈市",
+                "pid": 23718
+            },
+            {
+                "id": 24969,
+                "name": "咸宁市",
+                "pid": 23718
+            },
+            {
+                "id": 25065,
+                "name": "随州市",
+                "pid": 23718
+            },
+            {
+                "id": 25120,
+                "name": "恩施土家族苗族自治州",
+                "pid": 23718
+            },
+            {
+                "id": 25223,
+                "name": "省直辖县级行政区划",
+                "pid": 23718
+            }
+        ]
+    },
+    {
+        "id": 25313,
+        "name": "湖南省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 25314,
+                "name": "长沙市",
+                "pid": 25313
+            },
+            {
+                "id": 25496,
+                "name": "株洲市",
+                "pid": 25313
+            },
+            {
+                "id": 25618,
+                "name": "湘潭市",
+                "pid": 25313
+            },
+            {
+                "id": 25697,
+                "name": "衡阳市",
+                "pid": 25313
+            },
+            {
+                "id": 25903,
+                "name": "邵阳市",
+                "pid": 25313
+            },
+            {
+                "id": 26122,
+                "name": "岳阳市",
+                "pid": 25313
+            },
+            {
+                "id": 26271,
+                "name": "常德市",
+                "pid": 25313
+            },
+            {
+                "id": 26460,
+                "name": "张家界市",
+                "pid": 25313
+            },
+            {
+                "id": 26539,
+                "name": "益阳市",
+                "pid": 25313
+            },
+            {
+                "id": 26646,
+                "name": "郴州市",
+                "pid": 25313
+            },
+            {
+                "id": 26820,
+                "name": "永州市",
+                "pid": 25313
+            },
+            {
+                "id": 27029,
+                "name": "怀化市",
+                "pid": 25313
+            },
+            {
+                "id": 27250,
+                "name": "娄底市",
+                "pid": 25313
+            },
+            {
+                "id": 27345,
+                "name": "湘西土家族苗族自治州",
+                "pid": 25313
+            }
+        ]
+    },
+    {
+        "id": 27470,
+        "name": "广东省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 27471,
+                "name": "广州市",
+                "pid": 27470
+            },
+            {
+                "id": 27661,
+                "name": "韶关市",
+                "pid": 27470
+            },
+            {
+                "id": 27784,
+                "name": "深圳市",
+                "pid": 27470
+            },
+            {
+                "id": 27873,
+                "name": "珠海市",
+                "pid": 27470
+            },
+            {
+                "id": 27908,
+                "name": "汕头市",
+                "pid": 27470
+            },
+            {
+                "id": 27983,
+                "name": "佛山市",
+                "pid": 27470
+            },
+            {
+                "id": 28030,
+                "name": "江门市",
+                "pid": 27470
+            },
+            {
+                "id": 28120,
+                "name": "湛江市",
+                "pid": 27470
+            },
+            {
+                "id": 28255,
+                "name": "茂名市",
+                "pid": 27470
+            },
+            {
+                "id": 28388,
+                "name": "肇庆市",
+                "pid": 27470
+            },
+            {
+                "id": 28503,
+                "name": "惠州市",
+                "pid": 27470
+            },
+            {
+                "id": 28593,
+                "name": "梅州市",
+                "pid": 27470
+            },
+            {
+                "id": 28719,
+                "name": "汕尾市",
+                "pid": 27470
+            },
+            {
+                "id": 28787,
+                "name": "河源市",
+                "pid": 27470
+            },
+            {
+                "id": 28895,
+                "name": "阳江市",
+                "pid": 27470
+            },
+            {
+                "id": 28963,
+                "name": "清远市",
+                "pid": 27470
+            },
+            {
+                "id": 29064,
+                "name": "东莞市",
+                "pid": 27470
+            },
+            {
+                "id": 29102,
+                "name": "中山市",
+                "pid": 27470
+            },
+            {
+                "id": 29127,
+                "name": "潮州市",
+                "pid": 27470
+            },
+            {
+                "id": 29182,
+                "name": "揭阳市",
+                "pid": 27470
+            },
+            {
+                "id": 29296,
+                "name": "云浮市",
+                "pid": 27470
+            }
+        ]
+    },
+    {
+        "id": 29373,
+        "name": "广西壮族自治区",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 29374,
+                "name": "南宁市",
+                "pid": 29373
+            },
+            {
+                "id": 29525,
+                "name": "柳州市",
+                "pid": 29373
+            },
+            {
+                "id": 29654,
+                "name": "桂林市",
+                "pid": 29373
+            },
+            {
+                "id": 29820,
+                "name": "梧州市",
+                "pid": 29373
+            },
+            {
+                "id": 29894,
+                "name": "北海市",
+                "pid": 29373
+            },
+            {
+                "id": 29929,
+                "name": "防城港市",
+                "pid": 29373
+            },
+            {
+                "id": 29965,
+                "name": "钦州市",
+                "pid": 29373
+            },
+            {
+                "id": 30041,
+                "name": "贵港市",
+                "pid": 29373
+            },
+            {
+                "id": 30121,
+                "name": "玉林市",
+                "pid": 29373
+            },
+            {
+                "id": 30239,
+                "name": "百色市",
+                "pid": 29373
+            },
+            {
+                "id": 30389,
+                "name": "贺州市",
+                "pid": 29373
+            },
+            {
+                "id": 30456,
+                "name": "河池市",
+                "pid": 29373
+            },
+            {
+                "id": 30609,
+                "name": "来宾市",
+                "pid": 29373
+            },
+            {
+                "id": 30687,
+                "name": "崇左市",
+                "pid": 29373
+            }
+        ]
+    },
+    {
+        "id": 30783,
+        "name": "海南省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 30784,
+                "name": "海口市",
+                "pid": 30783
+            },
+            {
+                "id": 30835,
+                "name": "三亚市",
+                "pid": 30783
+            },
+            {
+                "id": 30844,
+                "name": "三沙市",
+                "pid": 30783
+            },
+            {
+                "id": 30853,
+                "name": "儋州市",
+                "pid": 30783
+            },
+            {
+                "id": 30873,
+                "name": "省直辖县级行政区划",
+                "pid": 30783
+            }
+        ]
+    },
+    {
+        "id": 31059,
+        "name": "重庆",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 31060,
+                "name": "重庆市",
+                "pid": 31059
+            },
+            {
+                "id": 31737,
+                "name": "自治县",
+                "pid": 31059
+            }
+        ]
+    },
+    {
+        "id": 32131,
+        "name": "四川省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 32132,
+                "name": "成都市",
+                "pid": 32131
+            },
+            {
+                "id": 32414,
+                "name": "自贡市",
+                "pid": 32131
+            },
+            {
+                "id": 32511,
+                "name": "攀枝花市",
+                "pid": 32131
+            },
+            {
+                "id": 32566,
+                "name": "泸州市",
+                "pid": 32131
+            },
+            {
+                "id": 32700,
+                "name": "德阳市",
+                "pid": 32131
+            },
+            {
+                "id": 32791,
+                "name": "绵阳市",
+                "pid": 32131
+            },
+            {
+                "id": 32968,
+                "name": "广元市",
+                "pid": 32131
+            },
+            {
+                "id": 33122,
+                "name": "遂宁市",
+                "pid": 32131
+            },
+            {
+                "id": 33223,
+                "name": "内江市",
+                "pid": 32131
+            },
+            {
+                "id": 33312,
+                "name": "乐山市",
+                "pid": 32131
+            },
+            {
+                "id": 33456,
+                "name": "南充市",
+                "pid": 32131
+            },
+            {
+                "id": 33708,
+                "name": "眉山市",
+                "pid": 32131
+            },
+            {
+                "id": 33795,
+                "name": "宜宾市",
+                "pid": 32131
+            },
+            {
+                "id": 33942,
+                "name": "广安市",
+                "pid": 32131
+            },
+            {
+                "id": 34073,
+                "name": "达州市",
+                "pid": 32131
+            },
+            {
+                "id": 34281,
+                "name": "雅安市",
+                "pid": 32131
+            },
+            {
+                "id": 34386,
+                "name": "巴中市",
+                "pid": 32131
+            },
+            {
+                "id": 34531,
+                "name": "资阳市",
+                "pid": 32131
+            },
+            {
+                "id": 34625,
+                "name": "阿坝藏族羌族自治州",
+                "pid": 32131
+            },
+            {
+                "id": 34817,
+                "name": "甘孜藏族自治州",
+                "pid": 32131
+            },
+            {
+                "id": 35125,
+                "name": "凉山彝族自治州",
+                "pid": 32131
+            }
+        ]
+    },
+    {
+        "id": 35447,
+        "name": "贵州省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 35448,
+                "name": "贵阳市",
+                "pid": 35447
+            },
+            {
+                "id": 35605,
+                "name": "六盘水市",
+                "pid": 35447
+            },
+            {
+                "id": 35702,
+                "name": "遵义市",
+                "pid": 35447
+            },
+            {
+                "id": 35971,
+                "name": "安顺市",
+                "pid": 35447
+            },
+            {
+                "id": 36070,
+                "name": "毕节市",
+                "pid": 35447
+            },
+            {
+                "id": 36358,
+                "name": "铜仁市",
+                "pid": 35447
+            },
+            {
+                "id": 36550,
+                "name": "黔西南布依族苗族自治州",
+                "pid": 35447
+            },
+            {
+                "id": 36699,
+                "name": "黔东南苗族侗族自治州",
+                "pid": 35447
+            },
+            {
+                "id": 36933,
+                "name": "黔南布依族苗族自治州",
+                "pid": 35447
+            }
+        ]
+    },
+    {
+        "id": 37055,
+        "name": "云南省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 37056,
+                "name": "昆明市",
+                "pid": 37055
+            },
+            {
+                "id": 37221,
+                "name": "曲靖市",
+                "pid": 37055
+            },
+            {
+                "id": 37368,
+                "name": "玉溪市",
+                "pid": 37055
+            },
+            {
+                "id": 37454,
+                "name": "保山市",
+                "pid": 37055
+            },
+            {
+                "id": 37536,
+                "name": "昭通市",
+                "pid": 37055
+            },
+            {
+                "id": 37698,
+                "name": "丽江市",
+                "pid": 37055
+            },
+            {
+                "id": 37770,
+                "name": "普洱市",
+                "pid": 37055
+            },
+            {
+                "id": 37884,
+                "name": "临沧市",
+                "pid": 37055
+            },
+            {
+                "id": 37977,
+                "name": "楚雄彝族自治州",
+                "pid": 37055
+            },
+            {
+                "id": 38091,
+                "name": "红河哈尼族彝族自治州",
+                "pid": 37055
+            },
+            {
+                "id": 38244,
+                "name": "文山壮族苗族自治州",
+                "pid": 37055
+            },
+            {
+                "id": 38359,
+                "name": "西双版纳傣族自治州",
+                "pid": 37055
+            },
+            {
+                "id": 38405,
+                "name": "大理白族自治州",
+                "pid": 37055
+            },
+            {
+                "id": 38530,
+                "name": "德宏傣族景颇族自治州",
+                "pid": 37055
+            },
+            {
+                "id": 38592,
+                "name": "怒江傈僳族自治州",
+                "pid": 37055
+            },
+            {
+                "id": 38628,
+                "name": "迪庆藏族自治州",
+                "pid": 37055
+            }
+        ]
+    },
+    {
+        "id": 38662,
+        "name": "西藏自治区",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 38663,
+                "name": "拉萨市",
+                "pid": 38662
+            },
+            {
+                "id": 38745,
+                "name": "日喀则市",
+                "pid": 38662
+            },
+            {
+                "id": 38970,
+                "name": "昌都市",
+                "pid": 38662
+            },
+            {
+                "id": 39120,
+                "name": "林芝市",
+                "pid": 38662
+            },
+            {
+                "id": 39184,
+                "name": "山南市",
+                "pid": 38662
+            },
+            {
+                "id": 39282,
+                "name": "那曲市",
+                "pid": 38662
+            },
+            {
+                "id": 39408,
+                "name": "阿里地区",
+                "pid": 38662
+            }
+        ]
+    },
+    {
+        "id": 39453,
+        "name": "陕西省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 39454,
+                "name": "西安市",
+                "pid": 39453
+            },
+            {
+                "id": 39640,
+                "name": "铜川市",
+                "pid": 39453
+            },
+            {
+                "id": 39683,
+                "name": "宝鸡市",
+                "pid": 39453
+            },
+            {
+                "id": 39816,
+                "name": "咸阳市",
+                "pid": 39453
+            },
+            {
+                "id": 39973,
+                "name": "渭南市",
+                "pid": 39453
+            },
+            {
+                "id": 40121,
+                "name": "延安市",
+                "pid": 39453
+            },
+            {
+                "id": 40252,
+                "name": "汉中市",
+                "pid": 39453
+            },
+            {
+                "id": 40441,
+                "name": "榆林市",
+                "pid": 39453
+            },
+            {
+                "id": 40638,
+                "name": "安康市",
+                "pid": 39453
+            },
+            {
+                "id": 40788,
+                "name": "商洛市",
+                "pid": 39453
+            }
+        ]
+    },
+    {
+        "id": 40906,
+        "name": "甘肃省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 40907,
+                "name": "兰州市",
+                "pid": 40906
+            },
+            {
+                "id": 41033,
+                "name": "嘉峪关市",
+                "pid": 40906
+            },
+            {
+                "id": 41040,
+                "name": "金昌市",
+                "pid": 40906
+            },
+            {
+                "id": 41061,
+                "name": "白银市",
+                "pid": 40906
+            },
+            {
+                "id": 41145,
+                "name": "天水市",
+                "pid": 40906
+            },
+            {
+                "id": 41283,
+                "name": "武威市",
+                "pid": 40906
+            },
+            {
+                "id": 41396,
+                "name": "张掖市",
+                "pid": 40906
+            },
+            {
+                "id": 41481,
+                "name": "平凉市",
+                "pid": 40906
+            },
+            {
+                "id": 41602,
+                "name": "酒泉市",
+                "pid": 40906
+            },
+            {
+                "id": 41694,
+                "name": "庆阳市",
+                "pid": 40906
+            },
+            {
+                "id": 41822,
+                "name": "定西市",
+                "pid": 40906
+            },
+            {
+                "id": 41952,
+                "name": "陇南市",
+                "pid": 40906
+            },
+            {
+                "id": 42161,
+                "name": "临夏回族自治州",
+                "pid": 40906
+            },
+            {
+                "id": 42301,
+                "name": "甘南藏族自治州",
+                "pid": 40906
+            }
+        ]
+    },
+    {
+        "id": 42412,
+        "name": "青海省",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 42413,
+                "name": "西宁市",
+                "pid": 42412
+            },
+            {
+                "id": 42501,
+                "name": "海东市",
+                "pid": 42412
+            },
+            {
+                "id": 42606,
+                "name": "海北藏族自治州",
+                "pid": 42412
+            },
+            {
+                "id": 42643,
+                "name": "黄南藏族自治州",
+                "pid": 42412
+            },
+            {
+                "id": 42681,
+                "name": "海南藏族自治州",
+                "pid": 42412
+            },
+            {
+                "id": 42727,
+                "name": "果洛藏族自治州",
+                "pid": 42412
+            },
+            {
+                "id": 42778,
+                "name": "玉树藏族自治州",
+                "pid": 42412
+            },
+            {
+                "id": 42834,
+                "name": "海西蒙古族藏族自治州",
+                "pid": 42412
+            }
+        ]
+    },
+    {
+        "id": 42888,
+        "name": "宁夏回族自治区",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 42889,
+                "name": "银川市",
+                "pid": 42888
+            },
+            {
+                "id": 42958,
+                "name": "石嘴山市",
+                "pid": 42888
+            },
+            {
+                "id": 42998,
+                "name": "吴忠市",
+                "pid": 42888
+            },
+            {
+                "id": 43054,
+                "name": "固原市",
+                "pid": 42888
+            },
+            {
+                "id": 43125,
+                "name": "中卫市",
+                "pid": 42888
+            }
+        ]
+    },
+    {
+        "id": 43175,
+        "name": "新疆维吾尔自治区",
+        "pid": 0,
+        "sons": [
+            {
+                "id": 43176,
+                "name": "乌鲁木齐市",
+                "pid": 43175
+            },
+            {
+                "id": 43315,
+                "name": "克拉玛依市",
+                "pid": 43175
+            },
+            {
+                "id": 43339,
+                "name": "吐鲁番市",
+                "pid": 43175
+            },
+            {
+                "id": 43378,
+                "name": "哈密市",
+                "pid": 43175
+            },
+            {
+                "id": 43440,
+                "name": "昌吉回族自治州",
+                "pid": 43175
+            },
+            {
+                "id": 43554,
+                "name": "博尔塔拉蒙古自治州",
+                "pid": 43175
+            },
+            {
+                "id": 43593,
+                "name": "巴音郭楞蒙古自治州",
+                "pid": 43175
+            },
+            {
+                "id": 43712,
+                "name": "阿克苏地区",
+                "pid": 43175
+            },
+            {
+                "id": 43836,
+                "name": "克孜勒苏柯尔克孜自治州",
+                "pid": 43175
+            },
+            {
+                "id": 43887,
+                "name": "喀什地区",
+                "pid": 43175
+            },
+            {
+                "id": 44109,
+                "name": "和田地区",
+                "pid": 43175
+            },
+            {
+                "id": 44224,
+                "name": "伊犁哈萨克自治州",
+                "pid": 43175
+            },
+            {
+                "id": 44386,
+                "name": "塔城地区",
+                "pid": 43175
+            },
+            {
+                "id": 44514,
+                "name": "阿勒泰地区",
+                "pid": 43175
+            },
+            {
+                "id": 44592,
+                "name": "自治区直辖县级行政区划",
+                "pid": 43175
+            }
+        ]
+    }
+]

+ 126 - 0
xhs/commons/collect_schedule_runner.py

@@ -0,0 +1,126 @@
+"""定时从调度库拉取任务、执行爬虫并上报状态的通用入口。"""
+import random
+import time
+import json
+import requests
+
+from commons.Logger import logger
+from commons.conn_mysql import MySQLPoolOnline
+from commons.feishu_webhook import send_text
+
+RESULT_REPORT_URL = "http://scheduleapi.findit.ltd/api/collect_equipment_execute/result_report"
+
+_TASK_SELECT = (
+    "select `id`,`collect_task_id`,`company_id`,`product_name`,`product_specs`,"
+    "`product_keyword`,`product_brand`,`sampling_cycle`,`sampling_start_time`,`sampling_end_time`,`collect_equipment_account_id`,`collect_region_id`,`collect_equipment_id`,`collect_round` "
+    "from `retrieve_collect_task_allocate` where `platform`=%s and `status`=1 limit 1"
+)
+
+
+class CollectScheduleRunner:
+    """按平台 ID 拉取一条待执行任务,运行爬虫并上报结果。"""
+
+    def __init__(self, platform_name, platform_id, spider_cls):
+        self.platform_name = platform_name
+        self.platform_id = platform_id
+        self.spider_cls = spider_cls
+        self.db_online = MySQLPoolOnline()
+        self.crawl_count = ""
+        self.task_id = ""
+        self.task_dict = None
+
+    def _report_status(self, status):
+        if status not in (2, 3, 4):
+            logger.warning("未知状态值: %s, 跳过状态上报", status)
+            return
+        now = int(time.time())
+        if status == 2:
+            params = {
+                "collect_task_allocate_id": self.task_id,
+                "status": status,
+                "finish_status": 0,
+                "start_time": now,
+            }
+        elif status == 3:
+            params = {
+                "collect_task_allocate_id": self.task_id,
+                "status": status,
+                "finish_status": 1,
+                "real_count": self.crawl_count,
+                "end_time": now,
+            }
+        else:
+            params = {
+                "collect_task_allocate_id": self.task_id,
+                "status": status,
+                "finish_status": 0,
+                "end_time": now,
+            }
+
+        try:
+            res = requests.get(RESULT_REPORT_URL, params=params, timeout=20)
+            res.raise_for_status()
+            logger.debug("状态上报: %s", res.text[:500])
+        except Exception as e:
+            logger.warning("状态上报失败: %s", e)
+
+    def get_task(self):
+        task_list = self.db_online.select_data(_TASK_SELECT, (self.platform_id,))
+        if not task_list:
+            return {}
+        task_dict = task_list[0]
+        self.task_id = task_dict["id"]
+        logger.info(json.dumps(task_dict))
+        return task_dict
+
+    def heartbeat_task(self):
+        url = "https://scheduleapi.findit.ltd/api/collect_equipment_execute/heartbeat"
+        params = {
+            "collect_task_allocate_id": self.task_id,
+        }
+        try:
+            res = requests.get(url, params=params, timeout=20)
+            logger.info("心跳任务上报成功")
+        except Exception as e:
+            logger.info("心跳任务上报失败")
+
+    def run(self):
+        self.task_dict = self.get_task()
+        if not self.task_dict:
+            logger.info("%s暂无任务", self.platform_name)
+            return
+        self._report_status(2)
+        self.crawl_count, is_success = self.spider_cls(self.task_dict).run()
+
+        self.heartbeat_task()
+        send_text(
+            f"{time.strftime('%Y-%m-%d %H:%M:%S')} 通知:\n"
+            f"平台: {self.platform_name}, 药品: {self.task_dict.get('product_name')}, "
+            f"爬取数据: {self.crawl_count}条"
+        )
+        self._report_status(3 if is_success else 4)
+
+
+def run_scheduled_loop(
+    platform_name,
+    platform_id,
+    spider_cls,
+    *,
+    interval_minutes=5,
+    sleep_seconds=3,
+):
+    """循环拉取任务并执行爬虫;每轮结束后休眠 interval_minutes 分钟。"""
+    idle_seconds = random.randint(180,300)
+    logger.info(
+        "循环任务已启动,平台=%s,每轮间隔 %s 秒",
+        platform_name,
+        idle_seconds,
+    )
+    while True:
+        try:
+            logger.info("开始执行%s爬虫任务", platform_name)
+            CollectScheduleRunner(platform_name, platform_id, spider_cls).run()
+            logger.info("%s爬虫任务执行完成", platform_name)
+        except Exception as e:
+            logger.error("%s爬虫任务执行失败: %s", platform_name, e, exc_info=True)
+        time.sleep(idle_seconds)

+ 23 - 0
xhs/commons/config.py

@@ -0,0 +1,23 @@
+# 京东设备id,对应数据库账号表的id
+
+JD_DEVICE_ID = 4
+
+#淘宝设备id,对应数据库账号表的id
+TB_DEVICE_ID= 1
+
+# 药师帮账号
+YSB_ACCOUNT = {
+    "username":"13484423007",
+    "password":"ljm123456"
+}
+
+# 壹药城账号
+YYC_ACCOUNT = {
+    "username":"18193030281",
+    "password":"a666666",
+    "token":"Sm45MzRmREtiaThGc1gvLzZaVnRFWXFjTlg4bVBZaytxZXoyRjR5OXB1NmFJMk84VDhadzI4NUdNUmhFZjFtZCszSnFGV1JUdThVTWtwUi9SVG9jUk01T0JNZ1RyUVZCNUsyNms0Z0F3OUhRc3JKRzZ1S0dFOWNLUVAwNFdweWRtaDkyS25iOUVwU3dnUC9FcVpnZWdSbS9QQm1qSHhsOS84RHlOc1czUWgxRTZkSEh4d2NUOCtkNzA1dTlLWjkzP2FwcElkPTEyNTAma2V5SWQ9MTI1MA==",
+    "user_id":387715
+}
+
+# 壹药城设备ID(用于内部调度 API)
+YYC_DEVICE_ID = "18193030281"

+ 176 - 0
xhs/commons/conn_mysql.py

@@ -0,0 +1,176 @@
+import logging
+import os
+
+import pymysql
+from dbutils.pooled_db import PooledDB
+from pymysql.cursors import DictCursor
+
+logger = logging.getLogger(__name__)
+
+# 连接池公共参数(可自行调大 maxconnections 等)
+_POOL_COMMON = {
+    "maxconnections": 10,
+    "mincached": 2,
+    "maxcached": 5,
+    "blocking": True,
+}
+
+
+def _create_pool(
+        host,
+        port,
+        user,
+        password,
+        database,
+):
+    return PooledDB(
+        creator=pymysql,
+        cursorclass=DictCursor,
+        charset="utf8mb4",
+        host=host,
+        port=port,
+        user=user,
+        password=password,
+        database=database,
+        **_POOL_COMMON,
+    )
+
+
+class _MySQLPoolBase:
+    """共用的取连接、写库、读库逻辑,避免两套类复制粘贴。"""
+
+    def __init__(self, pool):
+        self.pool = pool
+
+    def get_conn(self):
+        return self.pool.connection()
+
+    def _close(self, conn, cursor) -> None:
+        try:
+            if cursor:
+                cursor.close()
+        finally:
+            if conn:
+                conn.close()
+
+    def _write(self, fn, *args, **kwargs):
+        conn = self.get_conn()
+        cursor = conn.cursor()
+        try:
+            result = fn(cursor, *args, **kwargs)
+            conn.commit()
+            return result
+        except Exception as e:
+            logger.error("SQL 执行错误: %s", e)
+            conn.rollback()
+            return None
+        finally:
+            self._close(conn, cursor)
+
+    def execute_many(self, sql, data):
+        rows = list(data)
+        if not rows:
+            return
+
+        def _do(c, s, d):
+            c.executemany(s, d)
+
+        self._write(_do, sql, rows)
+
+    def execute_one(self, sql, data):
+        def _do(c, s, d):
+            c.execute(s, d)
+
+        self._write(_do, sql, data)
+
+    def update_data(self, sql, data):
+        self.execute_one(sql, data)
+
+    def select_data(self, sql,data=None):
+        """只读查询,不应 commit。"""
+        conn = self.get_conn()
+        cursor = conn.cursor()
+        try:
+            if data:
+                cursor.execute(sql,data)
+                return cursor.fetchall()
+            else:
+                cursor.execute(sql)
+                return cursor.fetchall()
+        except Exception as e:
+            logger.error("SQL 查询错误: %s", e)
+            return []
+        finally:
+            self._close(conn, cursor)
+
+    def execute(self, sql, data=None):
+        """通用写操作,返回受影响行数;失败返回 0。"""
+
+        def _do(c, s, d):
+            if d is not None:
+                return c.execute(s, d)
+            return c.execute(s)
+
+        result = self._write(_do, sql, data)
+        return result if isinstance(result, int) else 0
+
+
+class MySQLPool(_MySQLPoolBase):
+    """test2 库(可通过环境变量覆盖,便于部署;未设置则沿用原默认)。"""
+
+    def __init__(self):
+        pool = _create_pool(
+            host=os.environ.get("MYSQL_TEST2_HOST", "47.119.164.65"),
+            port=int(os.environ.get("MYSQL_TEST2_PORT", "3306")),
+            user=os.environ.get("MYSQL_TEST2_USER", "test_c"),
+            password=os.environ.get("MYSQL_TEST2_PASSWORD", "Dfwy@2025"),
+            database=os.environ.get("MYSQL_TEST2_DB", "test2"),
+        )
+        super().__init__(pool)
+
+
+class MySQLPool39(_MySQLPoolBase):
+    """drug_retrieve 库。"""
+
+    def __init__(self):
+        pool = _create_pool(
+            host=os.environ.get("MYSQL_DRUG_HOST", "39.108.116.125"),
+            port=int(os.environ.get("MYSQL_DRUG_PORT", "3306")),
+            user=os.environ.get("MYSQL_DRUG_USER", "drug_retrieve"),
+            password=os.environ.get("MYSQL_DRUG_PASSWORD", "Pem287cwM58jNpe2"),
+            database=os.environ.get("MYSQL_DRUG_DB", "drug_retrieve"),
+        )
+        super().__init__(pool)
+
+
+class MySQLPoolOnline(_MySQLPoolBase):
+    """drug_retrieve 库。"""
+
+    def __init__(self):
+        pool = _create_pool(
+            host=os.environ.get("MYSQL_DRUG_HOST", "120.25.48.236"),
+            port=int(os.environ.get("MYSQL_DRUG_PORT", "3306")),
+            user=os.environ.get("MYSQL_DRUG_USER", "drug_retrieve"),
+            password=os.environ.get("MYSQL_DRUG_PASSWORD", "ksCt3xm6chzdkafj"),
+            database=os.environ.get("MYSQL_DRUG_DB", "drug_retrieve"),
+        )
+        super().__init__(pool)
+
+class MySQLPoolOn2(_MySQLPoolBase):
+    """drug_retrieve 库。"""
+
+    def __init__(self):
+        pool = _create_pool(
+            host=os.environ.get("MYSQL_DRUG_HOST", "120.24.26.108"),
+            port=int(os.environ.get("MYSQL_DRUG_PORT", "3307")),
+            user=os.environ.get("MYSQL_DRUG_USER", "root"),
+            password=os.environ.get("MYSQL_DRUG_PASSWORD", "zhijiayun123456"),
+            database=os.environ.get("MYSQL_DRUG_DB", "drug_retrieve"),
+        )
+        super().__init__(pool)
+
+
+if __name__ == "__main__":
+    pool = MySQLPool()
+    rows = pool.select_data("SELECT 1 AS one")
+    print(rows)

+ 69 - 0
xhs/commons/feishu_webhook.py

@@ -0,0 +1,69 @@
+import requests
+import json
+import time
+
+WEBHOOK_URL = "https://open.feishu.cn/open-apis/bot/v2/hook/f8a70e6b-6b75-43c2-aaf2-9ba8f79f40f6"
+
+
+def send_text(text):
+    data = {
+        "msg_type": "text",
+        "content": {"text": text},
+    }
+    headers = {"Content-Type": "application/json"}
+    try:
+        response = requests.post(
+            WEBHOOK_URL,
+            headers=headers,
+            data=json.dumps(data),
+            timeout=5
+        )
+    except Exception as e:
+        pass
+
+
+def send_error_card(task_name, err_msg, mention_all=False):
+    """发送异常红色卡片"""
+    content = (
+        f"**任务**:{task_name}\n"
+        f"**状态**:❌ 失败\n"
+        f"**错误**:{err_msg}\n"
+        f"**时间**:{time.strftime('%Y-%m-%d %H:%M:%S')}"
+    )
+    if mention_all:
+        content = f"<at id=all></at>\n{content}"
+
+    data = {
+        "msg_type": "interactive",
+        "card": {
+            "config": {"wide_screen_mode": True},
+            "header": {
+                "template": "red",
+                "title": {"tag": "plain_text", "content": "异常告警"},
+            },
+            "elements": [
+                {
+                    "tag": "div",
+                    "text": {"tag": "lark_md", "content": content},
+                }
+            ],
+        },
+    }
+    headers = {"Content-Type": "application/json"}
+    response = requests.post(
+        WEBHOOK_URL,
+        headers=headers,
+        data=json.dumps(data),
+        timeout=5,
+    )
+    print(response.json())
+
+
+# 使用示例
+if __name__ == "__main__":
+    account1 = "aaaaa"
+    plat_form = "京东"
+    drug = "999小儿感冒颗粒24粒"
+    drug_count = 128
+    text = f"**重要通知** {str(time.strftime('%Y-%m-%d %H:%M:%S'))}\n 账号{account1}, {plat_form} 采集 {drug} 数据 {drug_count} 条"
+    send_text(text)

+ 127 - 0
xhs/commons/scheduler.py

@@ -0,0 +1,127 @@
+import time
+import requests
+from commons.Logger import get_spider_logger
+import threading
+
+logger = get_spider_logger('scheduler')
+
+
+class CrawlerScheduler:
+    """爬虫任务调度器"""
+
+    def __init__(self, DEVICE_ID, platform, heartbeat_interval=30):
+        """
+        初始化调度器
+
+        Args:
+            platform: 平台名称
+            heartbeat_url: 心跳上报URL
+            heartbeat_interval: 心跳间隔时间(秒)
+        """
+
+        self.username = DEVICE_ID
+        self.platform = platform
+
+        self._lock = threading.Lock()
+
+        self.heartbeat_url = 'http://pricesys.kailin.com.cn:8083/api/collect_task/heartbeat'
+        self.heartbeat_interval = heartbeat_interval
+        self.end = False
+        self.heartbeat_thread = None
+
+    def _heartbeat_reporter(self):
+        """守护线程:只负责上报心跳"""
+        headers = {'X-Crawler-Token': 'zhijiayun_crawler_2026'}
+        while True:
+            try:
+                json_data = {
+                    "platform": self.platform,
+                    "username": self.username
+                }
+                logger.info(f'上传心跳数据:{json_data},链接:{self.heartbeat_url}')
+                response = requests.post(
+                    self.heartbeat_url,
+                    json=json_data,
+                    headers=headers,
+                    timeout=3
+                )
+
+                print(f"[心跳] 发送成功: {response.status_code}")
+                result = response.json()
+
+                if self.end:
+                    break
+
+                if result.get('code') != 'success':
+                    logger.error(f'心跳回传异常:{result}')
+                else:
+                    logger.info(f'心跳回传:{result}')
+
+                time.sleep(self.heartbeat_interval)
+
+            except Exception as e:
+                print(e)
+                logger.error(e)
+                time.sleep(5)
+
+    def start(self):
+        """启动调度器"""
+        self.set_flag(False)
+        if self.heartbeat_thread and self.heartbeat_thread.is_alive():
+            return
+        self.heartbeat_thread = threading.Thread(
+            target=self._heartbeat_reporter,
+            daemon=True
+        )
+        self.heartbeat_thread.start()
+
+    def stop(self):
+        """停止调度器"""
+        self.set_flag(True)
+        logger.info('心跳停止')
+
+    def get_task(self):
+        try:
+            task_api = "http://pricesys.kailin.com.cn:8083/api/collect_task/pull"
+
+            headers = {'X-Crawler-Token': 'zhijiayun_crawler_2026'}
+
+            params = {
+                'platform': self.platform,
+                'username': self.username
+            }
+            logger.info(f'拉取任务参数:{params},链接:{task_api}')
+
+            response = requests.get(task_api, params=params, headers=headers, timeout=10)
+            result = response.json()
+            logger.info(f'拉取任务:{result}')
+            if result.get('code') == 'success':
+                return result.get('data').get('task')
+
+            print('拉取任务返回', result)
+
+        except Exception as e:
+            logger.error('获取任务报错', e)
+
+    def post_report(self, data):
+        try:
+            url = "http://pricesys.kailin.com.cn:8083/api/collect_task/report"
+            print('传给返回接口的数据', data)
+            logger.info(f'report上传数据:{data},链接:{url}')
+            headers = {'X-Crawler-Token': 'zhijiayun_crawler_2026'}
+            response = requests.post(url, json=data, headers=headers, timeout=5)
+            # 记录日志
+            result = response.json()
+
+            logger.info(f"report回传数据{result}")
+
+            if (result.get('code') != 'success'):
+                logger.error(f'翻页回传结果不成功:{result}')
+                self.stop()
+            print(f'任务进度上传 {result}')
+        except Exception as e:
+            logger.error(e)
+
+    def set_flag(self, value):
+        with self._lock:
+            self.end = value

+ 59 - 0
xhs/commons/sql_data.py

@@ -0,0 +1,59 @@
+"""
+retrieve_scrape_data 入库语句。
+列顺序须与 DrugPipeline.storge_data 中构建的 row 字段一致。
+"""
+
+# 与 pipelines/drug_pipelines.py 里 row 的键顺序一致(便于核对与扩展)
+RETRIEVE_SCRAPE_INSERT_COLUMNS = (
+    "platform_id",
+    "platform_item_id",
+    "enterprise_id",
+    "product_name",
+    "product_specs",
+    "product_brand",
+    "one_box_price",
+    "link_url",
+    "store_name",
+    "store_url",
+    "shipment_province_id",
+    "shipment_province_name",
+    "shipment_city_id",
+    "shipment_city_name",
+    "manufacturer",
+    "company_name",
+    "scrape_date",
+    "is_sold_out",
+    "min_price",
+    "sales",
+    "inventory",
+    "snapshot_url",
+    "approval_number",
+    "expiry_date",
+    "update_time",
+    "insert_time",
+    "number",
+    "task_id",
+    "anonymous_store_name",
+    "search_name",
+    "collect_config_info",
+    "area_info",
+    "city_name",
+    "city_id",
+    "province_name",
+    "province_id",
+    "collect_equipment_account_id",
+    "collect_region_id",
+    "collect_round",
+    "shop_id",
+    "company_id",
+)
+
+def _retrieve_scrape_insert_if_absent_sql() -> str:
+    cols = RETRIEVE_SCRAPE_INSERT_COLUMNS
+    column_list = ", ".join(f"`{c}`" for c in cols)
+    placeholder_list = ", ".join(["%s"] * len(cols))
+    return f"""INSERT IGNORE INTO `retrieve_scrape_data` ({column_list}) VALUES ({placeholder_list});"""
+sql_map = {
+    # 仅插入:同 platform_id, scrape_date, platform_item_id, collect_equipment_account_id, collect_round 已存在则跳过
+    "retrieve_scrape_insert_if_absent_sql": _retrieve_scrape_insert_if_absent_sql(),
+}

+ 5 - 0
xhs/readme.md

@@ -0,0 +1,5 @@
+# 创建虚拟环境
+python3 -m venv myenv
+
+# 激活虚拟环境
+source myenv/bin/activate

+ 150 - 0
xhs/requirements.txt

@@ -0,0 +1,150 @@
+adbutils==2.12.0
+aliyun-python-sdk-core==2.16.0
+aliyun-python-sdk-core-v3==2.11.5
+aliyun-python-sdk-kms==2.16.5
+annotated-doc==0.0.4
+annotated-types==0.7.0
+anyio==4.14.0
+Appium-Python-Client==5.3.1
+asgiref==3.11.1
+attrs==26.1.0
+Automat==25.4.16
+blinker==1.9.0
+build==1.5.0
+certifi==2026.5.20
+cffi==2.0.0
+charset-normalizer==3.4.7
+click==8.4.1
+constantly==23.10.4
+crcmod==1.7
+cryptography==49.0.0
+cssselect==1.4.0
+cuda-bindings==13.3.1
+cuda-pathfinder==1.5.5
+cuda-toolkit==13.0.2
+DBUtils==3.1.2
+ddddocr==1.6.1
+decorator==5.3.1
+defusedxml==0.7.1
+deprecation==2.1.0
+Django==5.0.6
+DrissionGet==1.2.1
+DrissionPage==4.1.1.4
+DrissionRecord==2.0.1
+easyocr==1.7.2
+et_xmlfile==2.0.0
+fastapi==0.137.1
+filelock==3.29.4
+Flask==3.1.3
+flatbuffers==25.12.19
+fsspec==2026.6.0
+greenlet==3.5.1
+h11==0.16.0
+httpcore==1.0.9
+httpx==0.28.1
+hyperlink==21.0.0
+idna==3.18
+image==1.5.33
+ImageIO==2.37.3
+Incremental==24.11.0
+itemadapter==0.13.1
+itemloaders==1.4.0
+itsdangerous==2.2.0
+Jinja2==3.1.6
+jmespath
+lazy-loader==0.5
+logger==1.4
+loguru==0.7.3
+lxml==6.1.1
+MarkupSafe==3.0.3
+mpmath==1.3.0
+networkx==3.4.2
+ninja==1.13.0
+numpy==2.2.6
+nvidia-cublas==13.1.1.3
+nvidia-cuda-cupti==13.0.85
+nvidia-cuda-nvrtc==13.0.88
+nvidia-cuda-runtime==13.0.96
+nvidia-cudnn-cu13==9.20.0.48
+nvidia-cufft==12.0.0.61
+# nvidia-cufile==1.15.1.6  # [移除] Windows 不支持
+nvidia-curand==10.4.0.35
+nvidia-cusolver==12.0.4.66
+nvidia-cusparse==12.6.3.3
+nvidia-cusparselt-cu13==0.8.1
+# nvidia-nccl-cu13==2.29.7  # [移除] Windows 不支持 NCCL
+nvidia-nvjitlink==13.0.88
+# nvidia-nvshmem-cu13==3.4.5  # [移除] Windows 不支持
+nvidia-nvtx==13.0.85
+onnxruntime==1.23.2
+opencv-python==4.13.0.92
+opencv-python-headless==4.13.0.92
+openpyxl==3.1.5
+oss2==2.19.1
+outcome==1.3.0.post0
+packaging==26.2
+pandas  # [解绑版本] 让 pip 自动匹配支持 3.10 的最新版
+parsel
+pillow==12.2.0
+pip-tools==7.5.3
+playwright==1.60.0
+Protego==0.6.1
+protobuf==7.35.1
+psutil==7.2.2
+pyclipper==1.4.0
+pycparser==3.0
+pycryptodome==3.23.0
+pydantic==2.13.4
+pydantic_core==2.46.4
+PyDispatcher==2.0.7
+pyee==13.0.1
+PyMySQL==1.2.0
+pyOpenSSL==26.3.0
+pyperclip==1.11.0
+pyproject_hooks==1.2.0
+PySocks==1.7.1
+pytesseract==0.3.13
+python-bidi==0.6.10
+python-dateutil==2.9.0.post0
+python-dotenv==1.2.2
+PyYAML==6.0.3
+queuelib==1.9.0
+redis==8.0.0
+requests==2.34.2
+requests-file==3.0.1
+retry2==0.9.5
+schedule==1.2.2
+scikit-image  # [解绑版本] 让 pip 自动匹配支持 3.10 的最新版
+scipy  # [解绑版本] 让 pip 自动匹配支持 3.10 的最新版
+Scrapy==2.16.0
+selenium==4.45.0
+service-identity==26.1.0
+setuptools==81.0.0
+shapely==2.1.2
+six==1.17.0
+sniffio==1.3.1
+sortedcontainers==2.4.0
+sqlparse==0.5.5
+starlette==1.3.1
+sympy==1.14.0
+tifffile
+tldextract==5.3.1
+# torch==2.12.0  # [移除] 请在全部跑完后单独安装 GPU 版
+# torchvision==0.27.0  # [移除] 请在全部跑完后单独安装 GPU 版
+tqdm==4.68.2
+trio==0.33.0
+trio-websocket==0.12.2
+# triton==3.7.0  # [移除] 官方仅支持 Linux
+Twisted==26.4.0
+typing-inspection==0.4.2
+typing_extensions==4.15.0
+uiautomator2==3.5.2
+urllib3==2.7.0
+uvicorn==0.49.0
+w3lib==2.4.1
+websocket-client==1.9.0
+Werkzeug==3.1.8
+wheel==0.47.0
+wsproto==1.3.2
+zope.interface==8.5
+baidu-aip==1.6.6

+ 95 - 0
xhs/spiders/pdd/box_script.py

@@ -0,0 +1,95 @@
+import re
+
+def extract_quantity_and_unit(text: str):
+    """
+    从药品选择文本中提取数量与单位。
+    优先级:
+    1. 选几发几 → (1, '盒')
+    2. X盒装 / X瓶装 → (X, 单位)
+    3. 已选: X盒 / 已选择: X瓶 → (X, 单位)
+    4. 显式数字/中文数字 + 单位(如 10盒, 三粒)
+    5. 特殊模式 *数字.../盒 或 *数字.../瓶(如 0.5g*15袋/盒)→ (1, 盒/瓶)
+    6. 数字ml/单位(如 100ml/盒)→ (1, 单位)
+    7. 单独数字ml(如 120ml)→ (1, '瓶')
+    """
+    unit_pattern = '瓶|盒|支|只|个|袋|包|粒|片|贴|罐|桶|条|板|枚|颗|管|套|丸|锭|听'
+
+    def chinese_to_int(s: str):
+        if s.isdigit():
+            return int(s)
+        chinese_map = {'一':1,'二':2,'三':3,'四':4,'五':5,
+                       '六':6,'七':7,'八':8,'九':9,'十':10,
+                       '百':100,'千':1000,'万':10000}
+        if len(s) == 1:
+            return chinese_map.get(s, 0)
+        if len(s) == 2 and s[0] in chinese_map and s[1] == '十':
+            return chinese_map[s[0]] * 10
+        if s == '十':
+            return 10
+        return 1
+
+    # 1. 选几发几
+    if re.search(r'选\d+发\d+|选[一二三四五六七八九十]+发[一二三四五六七八九十]+', text):
+        return (1, '盒')
+
+    # 2. 匹配 "X盒装" 或 "X瓶装"
+    package_match = re.search(r'(\d+)\s*(瓶|盒)装', text)
+    if package_match:
+        quantity = int(package_match.group(1))
+        unit = package_match.group(2)
+        return (quantity, unit)
+
+    # 3. 匹配 "已选: X盒" 或 "已选择: X瓶"
+    selected_match = re.search(r'已选[择::]\s*(\d+)\s*(盒|瓶)', text)
+    if selected_match:
+        quantity = int(selected_match.group(1))
+        unit = selected_match.group(2)
+        return (quantity, unit)
+
+    # 4. 显式数字/中文数字 + 单位(优先级最高)
+    explicit_match = re.search(
+        r'([一二三四五六七八九十百千万]+|\d+)\s*/?\s*(' + unit_pattern + r')',
+        text
+    )
+    if explicit_match:
+        num_str, unit = explicit_match.groups()
+        quantity = chinese_to_int(num_str)
+        return (quantity, unit)
+
+    # 5. 特殊模式:*数字.../盒 或 *数字.../瓶
+    special_match = re.search(r'\*\s*\d+\s*.*?/(盒|瓶)(?:装)?', text, re.IGNORECASE)
+    if special_match:
+        unit = special_match.group(1)
+        return (1, unit)
+
+    # 6. 数字ml/单位
+    implicit_match = re.search(r'[\d\.]+\s*ml\s*/\s*(' + unit_pattern + r')', text, re.I)
+    if implicit_match:
+        unit = implicit_match.group(1)
+        return (1, unit)
+
+    # 7. 单独数字ml → 1瓶
+    if re.search(r'[\d\.]+\s*ml', text, re.I):
+        return (1, '瓶')
+
+    return (None, None)
+
+# 测试使用,后续有问题在这里测试修改
+# if __name__ == '__main__':
+#     test_cases = [
+#         "已选: 1盒装",           # → 1盒
+#         "已选: 2盒",             # → 2盒
+#         "12瓶装",                # → 12瓶
+#         "0.5g*15袋/盒",          # → 1盒
+#         "0.33g*24/粒/盒",        # → 1盒
+#         "0.5g*100/片/瓶",        # → 1瓶
+#         "10mg*20/粒/瓶",         # → 1瓶
+#         "已选择: 25ml/瓶",      # → 1瓶
+#         "0.5g*15袋/盒装",        # → 1盒
+#         "10盒",                  # → 10盒
+#         "一支",                  # → 1支
+#         "两粒",                  # → 2粒
+#     ]
+#     for ex in test_cases:
+#         q, u = extract_quantity_and_unit(ex)
+#         print(f"{ex:30s} -> {q}{u if u else ''}")

+ 23 - 0
xhs/spiders/xhs/dump_hierarchy.py

@@ -0,0 +1,23 @@
+# coding=utf-8
+"""
+导出小红书当前页面的 UI 元素树,用于分析 xpath 结构
+使用方法:
+  1. 手机打开小红书,进入商品列表页
+  2. python dump_hierarchy.py
+  3. 查看生成的 xhs_hierarchy.xml
+"""
+
+import uiautomator2 as u2
+import time
+
+DEVICE_ID = "OV7T8PV8GEHYSCNB"
+
+d = u2.connect_usb(DEVICE_ID)
+time.sleep(1)
+
+xml = d.dump_hierarchy()
+with open("xhs_hierarchy.xml", "w", encoding="utf-8") as f:
+    f.write(xml)
+
+print("UI 元素树已导出到 xhs_hierarchy.xml")
+print(f"文件大小: {len(xml)} 字符")

+ 2112 - 0
xhs/spiders/xhs/main.py

@@ -0,0 +1,2112 @@
+# coding=utf-8
+"""
+小红书商品采集器
+==============
+调度/多线程/数据库框架模仿 PDD 模式,
+XHS 特有逻辑(弹窗检测、页面导航、数据提取)在本文件。
+
+使用方式:
+  1. 配置下方变量区
+  2. python main.py
+"""
+
+import sys
+import os
+from area_info.city_name_to_id import get_city
+import builtins
+import time
+import re
+import random
+import subprocess
+import datetime
+import threading
+import logging
+import json
+
+import requests
+import pymysql
+import uiautomator2 as u2
+import oss2
+import cv2
+import uuid
+from PIL import Image
+from pathlib import Path
+
+import xhs_config
+
+from commons.Logger import get_spider_logger
+from commons.feishu_webhook import send_text, send_error_card
+from commons.scheduler import CrawlerScheduler
+
+# 初始化统一日志(控制台 + logs/xhs_new2.log)
+_xhs_logger = get_spider_logger("xhs")
+logging.root.handlers = _xhs_logger.handlers
+logging.root.setLevel(_xhs_logger.level)
+
+# 保留原 print 的调用方式,但统一写入日志,避免到处翻控制台输出。
+_ORIGINAL_PRINT = builtins.print
+
+
+def print(*args, sep=" ", end="\n", file=None, flush=False):
+    message = sep.join(str(arg) for arg in args)
+    if end and end != "\n":
+        message = f"{message}{end}"
+    logged = False
+    try:
+        root_logger = logging.getLogger()
+        if root_logger.handlers:
+            root_logger.info(message.rstrip("\n"))
+            logged = True
+    except Exception:
+        logged = False
+    if not logged:
+        _ORIGINAL_PRINT(*args, sep=sep, end=end, file=file if file else sys.stdout, flush=flush)
+    if file not in (None, sys.stdout, sys.stderr):
+        _ORIGINAL_PRINT(*args, sep=sep, end=end, file=file, flush=flush)
+
+
+# 数据库连接池(与 PDD 同库:120.24.26.108:3307/drug_retrieve)
+from dbutils.pooled_db import PooledDB
+
+_db_pool = PooledDB(
+    creator=pymysql,
+    maxconnections=10,
+    mincached=2,
+    maxcached=5,
+    blocking=True,
+    charset="utf8mb4",
+    host="120.24.26.108",
+    port=3307,
+    user="root",
+    password="zhijiayun123456",
+    database="drug_retrieve",
+)
+
+
+def get_mysql():
+    """从连接池获取数据库连接(兼容旧接口)。调用方负责 conn.close() 归还连接。"""
+    return _db_pool.connection()
+
+
+# ============================================================
+# 常量
+# ============================================================
+HEARTBEAT_INTERVAL_SECONDS = 60
+SCHEDULER_INTERVAL_SECONDS = 600
+MANUAL_SCHEDULER_INTERVAL_SECONDS = 300
+TASK_REPORT_URL = "http://pricesys.kailin.com.cn:8083/api/collect_task/report"
+TASK_HEARTBEAT_URL = "http://pricesys.kailin.com.cn:8083/api/collect_task/heartbeat"
+TASK_API_HEADERS = {'X-Crawler-Token': 'zhijiayun_crawler_2026'}
+XHS_PLATFORM_ID = 15
+XHS_PACKAGE = "com.xingin.xhs"
+TABLE_NAME = "retrieve_scrape_data"
+SHOP_TABLE_NAME = "retrieve_scrape_shop_info"
+TASK_STATUS_PENDING = 1
+DEVICE_STATUS_IDLE = 0
+DEFAULT_MAX_COUNTS_LIMIT = 500
+_DEFAULT_PATH = Path(__file__).parent.parent / "pdd" / "city.json"
+
+# 手动任务模式开关(True 时忽略数据库,任务和设备完全自己定)
+USE_MANUAL_TASKS = False
+MANUAL_TASKS = [
+    {
+        "device_id": "XOYPOZDADU79VGVG",          # 必填:设备序列号
+        "search_key": "999 感冒灵胶囊 12",
+        "title_key": "感冒灵胶囊",
+        "spec_list": "12",
+        "brand": "999",
+        "save_search_key": "感冒灵胶囊",
+        "enterprise_id": 8,
+        "start_page": 0,
+        "end_page": 50,
+        "max_counts_limit": 500,
+        "sort": "默认",
+        "collect_round": 1,
+        "collect_equipment_account_id": 1,
+        "collect_region_id": 1,
+    },
+]
+
+# 内存占用状态(与 PDD 模式对齐)
+dispatch_lock = threading.Lock()
+running_task_ids = set()
+running_device_ids = set()
+worker_threads = {}
+scheduler_stop_event = threading.Event()
+scheduler_timer = None
+manual_dispatched_task_ids = set()
+
+# ============================================================
+# 工具函数
+# ============================================================
+
+def parse_optional_int(value, default=None):
+    if value in (None, ""):
+        return default
+    try:
+        return int(value)
+    except (TypeError, ValueError):
+        return default
+
+
+def get_adb_device_status_map():
+    try:
+        result = subprocess.run(
+            ["adb", "devices"],
+            capture_output=True,
+            text=True,
+            timeout=10,
+        )
+    except Exception as e:
+        logging.exception(f"读取 ADB 设备状态失败: {e}")
+        return {}
+    status_map = {}
+    for line in result.stdout.splitlines():
+        line = line.strip()
+        if not line or line.startswith("List of devices"):
+            continue
+        parts = line.split()
+        if len(parts) >= 2:
+            status_map[parts[0]] = parts[1]
+    return status_map
+
+
+def is_adb_device_online(device_id, adb_status_map=None):
+    device_id = str(device_id or "").strip()
+    if not device_id:
+        return False
+    if adb_status_map is None:
+        adb_status_map = get_adb_device_status_map()
+    return adb_status_map.get(device_id) == "device"
+
+
+# ============================================================
+# API 上报
+# ============================================================
+
+def report_api(task_id, platform, username, page=0, is_finished=0,
+               crawled_count=0, total_pages=0):
+    """向调度中心上报任务状态(完全对齐 PDD _send_report 格式)。"""
+    if USE_MANUAL_TASKS:
+        logging.info(f"手动任务模式:跳过接口上报 task_id={task_id}")
+        return
+
+    params = {
+        "task_id": task_id,
+        "platform": str(platform),
+        "username": str(username) if username else "",
+        "current_page": page,
+        "total_pages": total_pages,
+        "crawled_count": crawled_count,
+        "is_finished": is_finished,
+    }
+    print(params)
+    try:
+        res = requests.post(TASK_REPORT_URL, json=params, headers=TASK_API_HEADERS, timeout=20)
+        print(res.text)
+    except Exception as e:
+        logging.exception(f"上报接口异常 task_id={task_id}: {e}")
+
+
+def report_heartbeat(task_id, page=None, device_id=None):
+    """上报任务心跳"""
+    if USE_MANUAL_TASKS:
+        return
+
+    params = {
+        "collect_task_allocate_id": task_id,
+        "heartbeat_time": int(time.time()),
+        "current_page": page if page is not None else 0,
+        "device_id": device_id if device_id else "",
+    }
+
+    try:
+        requests.post(TASK_HEARTBEAT_URL, json=params, headers=TASK_API_HEADERS, timeout=10)
+        logging.debug(f"心跳上报 task_id={task_id}, page={page}")
+    except Exception as e:
+        logging.warning(f"心跳上报失败: {e}")
+
+
+# ============================================================
+# 任务 / 设备拉取
+# ============================================================
+
+def fetch_idle_xhs_devices():
+    """读取数据库里所有空闲的小红书设备;真实在线状态由 ADB 再二次判断。"""
+    conn = None
+    try:
+        conn = get_mysql()
+        with conn.cursor() as cursor:
+            sql = """
+                SELECT *
+                FROM retrieve_collect_equipment_account
+                WHERE platform = %s AND status = %s
+                ORDER BY id ASC
+            """
+            cursor.execute(sql, (XHS_PLATFORM_ID, DEVICE_STATUS_IDLE))
+            return cursor.fetchall()
+    except Exception as e:
+        logging.exception(f"读取空闲 XHS 设备失败: {e}")
+        return []
+    finally:
+        if conn:
+            conn.close()
+
+
+def fetch_manual_task_payloads():
+    payloads = []
+    adb_status_map = get_adb_device_status_map()
+    for idx, task in enumerate(MANUAL_TASKS):
+        task_id = parse_optional_int(task.get("task_id"), 900000 + idx + 1)
+        if task_id in manual_dispatched_task_ids:
+            continue
+
+        search_key = str(task.get("search_key", "")).strip()
+        if not search_key:
+            logging.warning(f"跳过手动任务,缺少 search_key: {task}")
+            continue
+
+        device_id = str(task.get("device_id", "")).strip()
+        if not device_id:
+            logging.warning(f"跳过手动任务 {task_id},缺少 device_id")
+            continue
+
+        if not is_adb_device_online(device_id, adb_status_map):
+            logging.info(
+                f"手动任务 {task_id} 设备 {device_id} ADB 不在线,"
+                f"status={adb_status_map.get(device_id, 'missing')}"
+            )
+            continue
+
+        with dispatch_lock:
+            if device_id in running_device_ids:
+                logging.info(f"手动任务 {task_id} 设备 {device_id} 已被占用,本轮跳过")
+                continue
+            running_task_ids.add(task_id)
+            running_device_ids.add(device_id)
+
+        title_key = task.get("title_key")
+        save_search_key = task.get("save_search_key")
+        payloads.append({
+            "task_id": task_id,
+            "enterprise_id": parse_optional_int(task.get("enterprise_id"), 0),
+            "platform": XHS_PLATFORM_ID,
+            "title_key": title_key if title_key not in (None, "") else search_key,
+            "spec_list": task.get("spec_list", ""),
+            "brand": task.get("brand", ""),
+            "search_key": search_key,
+            "save_search_key": save_search_key if save_search_key not in (None, "") else search_key,
+            "start_page": parse_optional_int(task.get("start_page"), 0),
+            "end_page": parse_optional_int(task.get("end_page"), None),
+            "max_counts_limit": parse_optional_int(task.get("max_counts_limit"), DEFAULT_MAX_COUNTS_LIMIT),
+            "collect_config_info": task.get("collect_config_info", ""),
+            "collect_equipment_account_id": task.get("collect_equipment_account_id", ""),
+            "collect_region_id": task.get("collect_region_id", ""),
+            "collect_round": task.get("collect_round", ""),
+            "sort": task.get("sort", "默认"),
+            "device_id": device_id,
+        })
+        manual_dispatched_task_ids.add(task_id)
+
+    if payloads:
+        logging.info(f"手动任务模式启用,本轮分发 {len(payloads)} 条任务")
+    else:
+        logging.info("手动任务模式启用,但没有可分发的新任务")
+    return payloads
+
+
+def fetch_runnable_task_payloads():
+    """获取本轮可执行的任务 payload 列表。"""
+    if USE_MANUAL_TASKS:
+        logging.info("手动任务模式启用,跳过数据库任务分配")
+        return fetch_manual_task_payloads()
+
+    # 数据库模式(暂未启用,保留入口)
+    logging.info("数据库模式:当前没有待执行任务")
+    return []
+
+
+# ============================================================
+# Worker 生命周期
+# ============================================================
+
+def cleanup_finished_workers():
+    with dispatch_lock:
+        dead_device_ids = [
+            device_id
+            for device_id, thread in worker_threads.items()
+            if not thread.is_alive()
+        ]
+        for device_id in dead_device_ids:
+            worker_threads.pop(device_id, None)
+            running_device_ids.discard(device_id)
+        orphan_device_ids = [
+            device_id
+            for device_id in running_device_ids
+            if device_id not in worker_threads
+        ]
+        for device_id in orphan_device_ids:
+            running_device_ids.discard(device_id)
+        if not worker_threads and running_task_ids:
+            logging.info(
+                f"cleanup: 无存活 worker,清空残留 running_task_ids: {running_task_ids}"
+            )
+            running_task_ids.clear()
+
+
+def has_active_workers():
+    cleanup_finished_workers()
+    with dispatch_lock:
+        return any(thread.is_alive() for thread in worker_threads.values())
+
+
+def all_manual_tasks_dispatched():
+    expected_task_ids = {
+        parse_optional_int(task.get("task_id"), 900000 + idx + 1)
+        for idx, task in enumerate(MANUAL_TASKS)
+    }
+    return expected_task_ids.issubset(manual_dispatched_task_ids)
+
+
+# ============================================================
+# 线程入口
+# ============================================================
+
+def run_task_worker(task_payload):
+    """单个任务线程的主入口:通过 CrawlerScheduler 维持心跳,驱动 XHS 采集并上报。"""
+    task_id = task_payload["task_id"]
+    device_id = task_payload["device_id"]
+    username = task_payload.get("username", device_id)
+    search_key = str(task_payload.get("search_key", "")).strip()
+    xhs = None
+    should_send_finish_notice = False
+
+    # 每个设备线程拥有自己的 CrawlerScheduler 实例,负责心跳 + 上报
+    scheduler = CrawlerScheduler(username, str(XHS_PLATFORM_ID))
+    scheduler.start()  # 启动后台心跳线程
+
+    try:
+        logging.info(f"[任务 {task_id}] 开始执行,设备: {device_id}")
+        print(task_payload)
+        xhs = XHS(
+            task_payload["search_key"],
+            device_id,
+            title_key=task_payload.get("title_key"),
+            spec_list=task_payload.get("spec_list"),
+            brand=task_payload.get("brand", ""),
+            save_search_key=task_payload.get("save_search_key"),
+            start_page=task_payload.get("start_page"),
+            end_page=task_payload.get("end_page"),
+            max_counts_limit=task_payload.get("max_counts_limit"),
+            collect_config_info=task_payload.get("collect_config_info", ""),
+            sort=task_payload.get("sort"),
+            platform=task_payload.get("platform"),
+            task_id=task_payload.get("task_id"),
+            enterprise_id=task_payload.get("enterprise_id"),
+            collect_round=task_payload.get("collect_round"),
+            collect_equipment_account_id=task_payload.get("collect_equipment_account_id", 1),
+            collect_region_id=task_payload.get("collect_region_id", 1),
+            username=task_payload.get("username"),
+        )
+        completed_normally = xhs.main()
+        if completed_normally:
+            logging.info(f"[任务 {task_id}] 执行完成,设备: {device_id}")
+        else:
+            logging.info(f"[任务 {task_id}] 已结束,设备: {device_id}")
+        should_send_finish_notice = True
+    except Exception as e:
+        end_page = task_payload.get("start_page")
+        err_msg = str(e)
+        if xhs is not None:
+            end_page = getattr(xhs, "page", end_page)
+            xhs.finish_task_abnormally(end_page, f"任务执行异常: {err_msg}")
+        else:
+            report_api(task_id, platform=XHS_PLATFORM_ID, username="unknown",
+                       page=end_page, is_finished=0)
+        logging.exception(f"[任务 {task_id}] 执行异常,设备: {device_id},错误: {err_msg}")
+        send_error_card(
+            task_name=f"XHS任务(task_id={task_id}, device={device_id}, key={search_key})",
+            err_msg=err_msg,
+            mention_all=False,
+        )
+        should_send_finish_notice = True
+    finally:
+        scheduler.stop()  # 停止心跳
+        # 释放内存占用标记
+        with dispatch_lock:
+            running_device_ids.discard(device_id)
+            worker_threads.pop(device_id, None)
+        if should_send_finish_notice:
+            scraped_count = getattr(xhs, "max_counts", 0) if xhs is not None else 0
+            notify_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+            drug_name = str(task_payload.get("search_key", "")).strip()
+            send_text(
+                f"{notify_time} 通知:\n"
+                f"平台: 小红书, 药品: {drug_name}, 爬取数据: {scraped_count}条"
+            )
+
+
+# ============================================================
+# 派单
+# ============================================================
+
+def dispatch_pending_tasks():
+    """执行一轮派单,把每个可运行任务绑定到对应设备线程。"""
+    cleanup_finished_workers()
+    task_payloads = fetch_runnable_task_payloads()
+    if not task_payloads:
+        return
+
+    for task_payload in task_payloads:
+        device_id = task_payload["device_id"]
+        try:
+            thread = threading.Thread(
+                target=run_task_worker,
+                args=(task_payload,),
+                daemon=True,
+                name=f"xhs-{device_id}",
+            )
+            with dispatch_lock:
+                worker_threads[device_id] = thread
+            thread.start()
+            logging.info(f"[任务 {task_payload['task_id']}] 已分发到设备 {device_id}")
+        except Exception:
+            with dispatch_lock:
+                running_task_ids.discard(task_payload["task_id"])
+                running_device_ids.discard(device_id)
+                worker_threads.pop(device_id, None)
+            raise
+
+
+# ============================================================
+# 定时调度
+# ============================================================
+
+def schedule_dispatch(delay_seconds=SCHEDULER_INTERVAL_SECONDS, job_func=None):
+    """注册下一轮调度定时器。"""
+    global scheduler_timer
+    if scheduler_stop_event.is_set():
+        return
+    if job_func is None:
+        job_func = scheduled_dispatch_job
+    scheduler_timer = threading.Timer(delay_seconds, job_func)
+    scheduler_timer.daemon = False
+    scheduler_timer.name = "xhs-scheduler"
+    scheduler_timer.start()
+
+
+def scheduled_dispatch_job():
+    try:
+        dispatch_pending_tasks()
+    except Exception as e:
+        logging.exception(f"XHS 定时调度异常: {e}")
+    finally:
+        schedule_dispatch(SCHEDULER_INTERVAL_SECONDS)
+
+
+def manual_scheduled_dispatch_job():
+    try:
+        dispatch_pending_tasks()
+    except Exception as e:
+        logging.exception(f"XHS 手动任务定时调度异常: {e}")
+    finally:
+        if all_manual_tasks_dispatched() and not has_active_workers():
+            logging.info("手动任务模式:全部任务已完成,停止轮询并退出")
+            scheduler_stop_event.set()
+            return
+        schedule_dispatch(MANUAL_SCHEDULER_INTERVAL_SECONDS, manual_scheduled_dispatch_job)
+
+
+# ============================================================
+# ====== 变量区结束 ======
+# ============================================================
+
+
+class XHS:
+    """小红书商品详情页采集器"""
+
+    SEP = "=" * 60
+    SLEEP_CLICK = 1.5
+    SLEEP_APP_START = 3
+    SLEEP_CLIPBOARD = 0.8
+
+    # 弹窗检测
+    POPUP_XPATHS = [
+        # 原有
+        '//*[@text="立即领取"]',
+        '//*[@text="送你 66周年庆礼券 啦 "]',
+        '//*[contains(@text, "可用")]',
+        # 优惠券弹窗
+        '//*[@text="恭喜领取成功 "]',
+        '//*[@text="立即使用"]',
+        '//*[@text="无门槛"]',
+        '//*[contains(@text, "后失效")]',
+    ]
+    POPUP_THRESHOLD = 3
+
+    # 列表页判定锚点
+    LIST_PAGE_ANCHORS = [
+        '//*[@text="搜索"]',
+        '//*[@text="全部"]',
+        '//*[@text="用户"]',
+        '//*[@text="商品"]',
+        '//*[@content-desc="全部删除"]',
+    ]
+    LIST_PAGE_HIT_THRESHOLD = 2
+
+    def __init__(self, search_key, device_id,
+                 title_key=None, spec_list=None, brand="",
+                 save_search_key=None, start_page=0, end_page=None,
+                 max_counts_limit=None, collect_config_info="",
+                 sort=None, platform=None, task_id=None,
+                 enterprise_id=None, collect_round=None,
+                 collect_equipment_account_id=None, collect_region_id=None,
+                 username=None):
+        self.package_name = XHS_PACKAGE
+        self.device_id = device_id
+        self.d = None
+
+        # 任务参数
+        self.username = username  # 调度系统账号名
+        self.enterprise_id = enterprise_id
+        self.task_id = task_id
+        self.platform = platform
+        self.sort = sort
+        self.search_key = str(search_key or "").strip()
+        self.title_key = title_key if title_key is not None else search_key
+        self.spec_list = self._normalize_rule_list(spec_list)
+        self.brand = str(brand or "").strip()
+        self.save_search_key = save_search_key or search_key
+        self.start_page = max(parse_optional_int(start_page, 0), 0)
+        self.end_page = max(parse_optional_int(end_page, 0), 0)
+        self.max_counts_limit = max_counts_limit
+        self.collect_config_info = collect_config_info or ""
+        self.collect_round = collect_round
+        self.collect_equipment_account_id = collect_equipment_account_id
+        self.collect_region_id = collect_region_id
+
+        # 运行状态
+        self.page = self.start_page
+        self.max_counts = 0
+        self.unrelated_data = 0
+        self.click_counts = 0
+        self.finish_reported = False
+        self.program_start_time = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+        self._heartbeat_running = False
+
+        # 表名(使用 XHS 独立表)
+        self.table_name = TABLE_NAME
+        self.shop_table_name = SHOP_TABLE_NAME
+
+        self._city_lookup_cache = None
+
+        # OSS 快照(从 xhs_config 拿)
+        self.oss_config = {
+            "access_key_id": xhs_config.access_key_id,
+            "access_key_secret": xhs_config.access_key_secret,
+            "endpoint": xhs_config.endpoint,
+            "bucket_name": xhs_config.bucket_name,
+            "oss_prefix": xhs_config.oss_prefix,
+        }
+        self.address_region_index = self.build_address_region_index(_DEFAULT_PATH)
+
+    def _norm_region_name(self, name):
+        if name is None:
+            return ""
+        text = str(name).strip()
+        if not text:
+            return ""
+        return re.sub(r'(省|市|自治区|特别行政区)$', '', text)
+
+    def build_address_region_index(self, file_path):
+        pairs = []
+        try:
+            with open(file_path, 'r', encoding='utf-8') as f:
+                data = json.load(f)
+            for province in data:
+                p_name = str(province.get("name") or "").strip()
+                if not p_name:
+                    continue
+                p_candidates = {p_name, self._norm_region_name(p_name)}
+                cities = province.get("sons") or []
+                if not cities:
+                    pairs.append({
+                        "province": p_name,
+                        "city": "",
+                        "province_candidates": p_candidates,
+                        "city_candidates": set()
+                    })
+                    continue
+                for city in cities:
+                    c_name = str(city.get("name") or "").strip()
+                    c_candidates = {c_name, self._norm_region_name(c_name)} if c_name else set()
+                    pairs.append({
+                        "province": p_name,
+                        "city": c_name,
+                        "province_candidates": p_candidates,
+                        "city_candidates": c_candidates
+                    })
+        except Exception as e:
+            print(f"构建地址省市索引失败: {e}")
+        return pairs
+
+    def infer_region_from_address(self, address):
+        text = str(address or "").strip()
+        if not text:
+            return "", ""
+        for item in self.address_region_index:
+            if any(k and k in text for k in item["province_candidates"]):
+                city = item["city"]
+                if city and any(k and k in text for k in item["city_candidates"]):
+                    return item["province"], city
+        for item in self.address_region_index:
+            if any(k and k in text for k in item["province_candidates"]):
+                return item["province"], item["city"]
+        return "", ""
+
+    def _load_city_lookup(self):
+        if self._city_lookup_cache is not None:
+            return self._city_lookup_cache
+
+        base_dir = os.path.dirname(os.path.abspath(__file__))
+        candidate_paths = [
+            os.path.join(base_dir, "..", "pdd", "city.json"),
+        ]
+
+        city_data = []
+        loaded_path = ""
+        for path in candidate_paths:
+            if not os.path.exists(path):
+                continue
+            try:
+                with open(path, "r", encoding="utf-8") as f:
+                    candidate_data = json.load(f)
+                if isinstance(candidate_data, list):
+                    city_data = candidate_data
+                    loaded_path = path
+                    break
+            except Exception as exc:
+                print(f"加载 city.json 失败: {path}, error={exc}")
+
+        province_to_id = {}
+        province_city_to_id = {}
+        global_city_matches = {}
+
+        for province in city_data:
+            province_norm = self._normalize_region_name(province.get("name", ""), "province")
+            province_id = parse_optional_int(province.get("id"), 0) or 0
+
+            if province_norm and province_id and province_norm not in province_to_id:
+                province_to_id[province_norm] = province_id
+
+            city_map = {}
+            for city in province.get("sons", []) or []:
+                city_norm = self._normalize_region_name(city.get("name", ""), "city")
+                city_id = parse_optional_int(city.get("id"), 0) or 0
+                if city_norm and city_id and city_norm not in city_map:
+                    city_map[city_norm] = city_id
+                if city_norm and city_id and province_id:
+                    global_city_matches.setdefault(city_norm, []).append((province_id, city_id, province_norm))
+
+            if province_norm and province_norm not in province_city_to_id:
+                province_city_to_id[province_norm] = city_map
+
+        if loaded_path:
+            print(f"已加载 city.json: {loaded_path}")
+        else:
+            print("未找到可用 city.json,省市ID将回填为 0")
+
+        self._city_lookup_cache = {
+            "province_to_id": province_to_id,
+            "province_city_to_id": province_city_to_id,
+            "global_city_matches": global_city_matches,
+        }
+        return self._city_lookup_cache
+
+    def _resolve_region_ids(self, province_name, city_name):
+        city_id, province_id, _, _ = get_city(city_name or province_name)
+        return province_id or 0, city_id or 0
+
+    @staticmethod
+    def _normalize_region_name(name, level):
+        text = re.sub(r"\s+", "", str(name or ""))
+        text = text.replace(" ", "")
+        if not text:
+            return ""
+
+        if level == "province":
+            province_alias = {
+                "内蒙古自治区": "内蒙古",
+                "广西壮族自治区": "广西",
+                "宁夏回族自治区": "宁夏",
+                "新疆维吾尔自治区": "新疆",
+                "西藏自治区": "西藏",
+                "香港特别行政区": "香港",
+                "澳门特别行政区": "澳门",
+            }
+            if text in province_alias:
+                return province_alias[text]
+
+            for suffix in ("特别行政区", "维吾尔自治区", "回族自治区", "壮族自治区", "自治区", "省", "市"):
+                if text.endswith(suffix) and len(text) > len(suffix):
+                    return text[:-len(suffix)]
+            return text
+
+        city_alias = {
+            "北京市": "北京",
+            "天津市": "天津",
+            "上海市": "上海",
+            "重庆市": "重庆",
+        }
+        if text in city_alias:
+            return city_alias[text]
+
+        for suffix in ("自治州", "地区", "盟", "州", "市"):
+            if text.endswith(suffix) and len(text) > len(suffix):
+                return text[:-len(suffix)]
+        return text
+
+    # ============================================================
+    # 工具方法
+    # ============================================================
+
+    @staticmethod
+    def _normalize_rule_list(value):
+        if value is None:
+            return []
+        if isinstance(value, (list, tuple, set)):
+            raw_values = value
+        else:
+            raw_values = [value]
+        return [str(v).strip() for v in raw_values if str(v).strip()]
+
+    @staticmethod
+    def _normalize_match_text(value):
+        return re.sub(r'\s+', '', str(value or '')).lower()
+
+    @staticmethod
+    def get_sleep_time():
+        return random.uniform(0.5, 1.0)
+
+    @staticmethod
+    def get_current_date():
+        return datetime.datetime.now().strftime('%Y/%m/%d')
+
+    @staticmethod
+    def _strip_content_desc_prefix(text, prefix):
+        for sep in (",", ","):
+            text = text.replace(f"{prefix}{sep}", "")
+        return text.strip()
+
+    def _safe_get_attr(self, xpath, attr="text"):
+        try:
+            el = self.d.xpath(xpath)
+            if not el.exists:
+                return ""
+            if attr == "content_desc":
+                return (el.info.get('contentDescription') or "").strip()
+            return (el.text or "").strip()
+        except Exception:
+            return ""
+
+    def _scroll_until_found(self, xpath, max_swipes=5, direction="up", scale=0.3):
+        el = self.d.xpath(xpath)
+        if el.exists:
+            return el
+        for _ in range(max_swipes):
+            self.d.swipe_ext(direction, scale=scale)
+            time.sleep(self.get_sleep_time())
+            el = self.d.xpath(xpath)
+            if el.exists:
+                return el
+        return None
+
+    def _count_popup_elements(self):
+        return sum(1 for xp in self.POPUP_XPATHS if self.d.xpath(xp).exists)
+
+    def _dismiss_popup_if_exists(self):
+        count = self._count_popup_elements()
+        if count >= self.POPUP_THRESHOLD:
+            print(f"  [弹窗检测] 命中 {count} 个特征,关闭弹窗")
+            self.d.press("back")
+            time.sleep(self.get_sleep_time())
+            return True
+        return False
+
+    def _find_with_popup_retry(self, find_func, step_name="", max_retries=2):
+        for attempt in range(max_retries):
+            result = find_func()
+            if result:
+                return result
+            if attempt < max_retries - 1:
+                print(f"  [{step_name}] 未找到,关闭弹窗后重试 ({attempt + 1}/{max_retries - 1})")
+                self._dismiss_popup_if_exists()
+                time.sleep(random.uniform(1.5, 2))
+        return None
+
+    def _match_any_keyword(self, text, keywords):
+        keyword_list = self._normalize_rule_list(keywords)
+        if not keyword_list:
+            return True
+        normalized = self._normalize_match_text(text)
+        return any(self._normalize_match_text(k) in normalized for k in keyword_list)
+
+    def is_on_list_page(self):
+        hits = sum(1 for xp in self.LIST_PAGE_ANCHORS if self.d.xpath(xp).exists)
+        return hits >= self.LIST_PAGE_HIT_THRESHOLD
+
+    def is_title_useful(self, title):
+        if self.title_key and not self._match_any_keyword(title, self.title_key):
+            print(f"    不匹配: 标题关键词「{self.title_key}」")
+            return False
+        if self.brand and not self._match_any_keyword(title, self.brand):
+            print(f"    不匹配: 品牌「{self.brand}」")
+            return False
+        if self.spec_list and not self._match_any_keyword(title, self.spec_list):
+            print(f"    不匹配: 规格「{self.spec_list}」")
+            return False
+        return True
+
+    # ============================================================
+    # 设备连接 & App 控制
+    # ============================================================
+
+    def connect_device(self):
+        try:
+            self.d = u2.connect_usb(self.device_id)
+            
+            # (已移除全局 ANR 监控,后台轮询会导致滑动时卡顿/抽搐,触发防跑封控)
+            
+            self._restart_uiautomator_services()
+            print(f'[连接成功] 设备: {self.device_id}')
+            return True
+        except Exception as e:
+            print(f'[连接失败] {self.device_id}: {e}')
+            return False
+
+    def _restart_uiautomator_services(self):
+        stop_cmd = f'adb -s {self.device_id} shell /data/local/tmp/atx-agent server -d --stop'
+        start_cmd = f'adb -s {self.device_id} shell /data/local/tmp/atx-agent server -d'
+        subprocess.run(stop_cmd, capture_output=True, text=True, shell=True)
+        time.sleep(self.get_sleep_time())
+        subprocess.run(start_cmd, capture_output=True, text=True, shell=True)
+        time.sleep(self.get_sleep_time())
+
+    def start_app(self):
+        self.d.app_start(self.package_name)
+        time.sleep(self.SLEEP_APP_START)
+
+    def get_screen_size(self):
+        if not hasattr(self, '_cached_screen_w'):
+            info = self.d.info
+            self._cached_screen_w = info.get('displayWidth', 720)
+            self._cached_screen_h = info.get('displayHeight', 1640)
+        return self._cached_screen_w, self._cached_screen_h
+
+    # ============================================================
+    # 页面导航
+    # ============================================================
+
+    def back_to_list_page(self, max_attempts=5):
+        for _ in range(max_attempts):
+            if self.is_on_list_page():
+                return True
+            self.d.press("back")
+            time.sleep(self.get_sleep_time())
+        return self.is_on_list_page()
+
+    def enter_search_page(self):
+        """首页 → 市集 → 搜索框 → 搜索 → 商品列表页"""
+        # 1. 点市集/购物/商城 (兼容不同版本的小红书)
+        el = None
+        for txt in ["购物", "市集", "商城"]:
+            tmp_el = self.d.xpath(f'//*[@text="{txt}"]')
+            if tmp_el.exists:
+                el = tmp_el
+                print(f"找到入口: {txt}")
+                break
+                
+        if not el:
+            print("[错误] 未找到「市集/购物/商城」")
+            return False
+        el.click()
+        time.sleep(random.uniform(1.5, 2))
+        self._dismiss_popup_if_exists()
+
+        # 2. 点搜索入口(弹窗遮挡时自动重试)
+        def _find_search_entry():
+            tvs = self.d.xpath('//android.widget.TextView').all()
+            for tv in tvs:
+                try:
+                    b = tv.info.get('bounds', {})
+                    if 150 <= b.get('top', 0) <= 180 and 200 <= b.get('bottom', 0) <= 250:
+                        tv.click()
+                        return True
+                except Exception:
+                    continue
+            return False
+
+        if not self._find_with_popup_retry(_find_search_entry, step_name="搜索入口"):
+            print("[错误] 未找到搜索入口")
+            return False
+        time.sleep(random.uniform(1, 1.5))
+
+        # 3. 输入关键字
+        spec_str = " ".join(self.spec_list) if self.spec_list else ""
+        search_key = f"{self.brand} {self.title_key} {spec_str}".strip()
+        print(f"  搜索词: {search_key}")
+
+        def _find_edit_text():
+            edit = self.d.xpath('//android.widget.EditText')
+            if edit.exists:
+                edit.click()
+                return True
+            return False
+
+        if not self._find_with_popup_retry(_find_edit_text, step_name="搜索输入框"):
+            print("[错误] 未找到搜索输入框")
+            return False
+        time.sleep(0.5)
+        try:
+            self.d.send_keys(search_key, clear=True)
+        except Exception as e:
+            print(f"  [警告] send_keys 失败 ({e}),尝试使用 set_text 兜底输入")
+            try:
+                # 兼容某些手机(或模拟器)上 uiautomator2 切换输入法报错的问题
+                self.d(className="android.widget.EditText").set_text(search_key)
+            except Exception as e2:
+                print(f"  [错误] set_text 也失败: {e2}")
+        time.sleep(random.uniform(0.5, 1))
+
+        # 4. 点搜索按钮
+        def _find_search_btn():
+            btn = self.d.xpath('//*[@text="搜索"]')
+            if btn.exists:
+                btn.click()
+                return True
+            return False
+
+        if not self._find_with_popup_retry(_find_search_btn, step_name="搜索按钮"):
+            print("[错误] 未找到搜索按钮")
+            return False
+        time.sleep(random.uniform(1.5, 2))
+        self._dismiss_popup_if_exists()
+
+        print("  已进入商品列表页")
+        return True
+
+    # ============================================================
+    # 商品卡片识别
+    # ============================================================
+
+    def get_product_cards(self):
+        cards = []
+        for idx in (1, 2, 3):
+            candidates = self.d.xpath(
+                f'(//androidx.recyclerview.widget.RecyclerView)[{idx}]/android.widget.FrameLayout'
+            ).all()
+            for c in candidates:
+                try:
+                    bounds = c.info.get('bounds', {})
+                    h = bounds.get('bottom', 0) - bounds.get('top', 0)
+                    if h > 400:
+                        cards = candidates
+                        break
+                except Exception:
+                    continue
+            if cards:
+                break
+        if not cards:
+            return []
+        visible = []
+        for card in cards:
+            try:
+                bounds = card.info.get('bounds', {})
+                top = bounds.get('top', 0)
+                bottom = bounds.get('bottom', 0)
+                h = bottom - top
+                if h > 400 and top >= 554 and bottom <= 2600:
+                    visible.append(card)
+            except Exception:
+                continue
+        return visible
+
+    # ============================================================
+    # 1. 提取标题(拿不到返回 "")
+    # ============================================================
+
+    def get_title(self):
+        print("\n[1/7] 提取标题...")
+        self._dismiss_popup_if_exists()
+        try:
+            title = self._safe_get_attr(
+                '//*[contains(@content-desc, "商品名称")]', "content_desc"
+            )
+            if title:
+                title = self._strip_content_desc_prefix(title, "商品名称")
+                print(f"  标题={title}")
+                return title
+            text_els = self.d.xpath('//android.widget.TextView').all()
+            longest = ""
+            for el in text_els:
+                t = (el.text or "").strip()
+                if len(t) > len(longest):
+                    longest = t
+            if longest and len(longest) > 5:
+                print(f"  标题(fallback)={longest}")
+                return longest
+        except Exception as e:
+            print(f"  标题提取异常: {e}")
+        print("  [失败] 未找到标题,返回空")
+        return ""
+
+    # ============================================================
+    # 2. 提取价格(拿不到返回 None)
+    # ============================================================
+
+    def get_price(self):
+        print("\n[2/7] 提取价格...")
+        self._dismiss_popup_if_exists()
+        try:
+            buy_btn = self.d.xpath('//*[@text="立即购买"]')
+            if not buy_btn.exists:
+                buy_btn = self.d.xpath('//*[@text="领券购买"]')
+            if not buy_btn.exists:
+                print("  [失败] 未找到「购买选项」,价格留空")
+                return None
+            buy_btn.click()
+            print("  点击「购买」")
+            time.sleep(self.SLEEP_CLICK)
+
+            price_str = ""
+            price_xpath = '//*[contains(@content-desc, "到手价")]'
+            if self.d.xpath(price_xpath).exists:
+                price_str = self._safe_get_attr(price_xpath, "content_desc")
+            else:
+                fallback_xpath = '//*[contains(@content-desc, "¥")]'
+                if self.d.xpath(fallback_xpath).exists:
+                    price_str = self._safe_get_attr(fallback_xpath, "content_desc")
+
+            self.d.press("back")
+            time.sleep(self.get_sleep_time())
+
+            if price_str:
+                match = re.search(r'¥([\d\.]+)', price_str)
+                if match:
+                    price = float(match.group(1))
+                    print(f"  价格={price} 元")
+                    return price
+        except Exception as e:
+            print(f"  价格提取异常: {e}")
+            try:
+                self.d.press("back")
+            except Exception:
+                pass
+        print("  [失败] 无法解析价格,返回 None")
+        return None
+
+    # ============================================================
+    # 3. 提取规格(拿不到返回 "")
+    # ============================================================
+
+    def get_spec(self, current_price=None):
+        print("\n[3/7] 提取规格...")
+        self._dismiss_popup_if_exists()
+        spec = ""
+        try:
+            # 必须打开购买弹窗才能看到完整的规格列表和价格绑定
+            buy_btn = self.d.xpath('//*[@text="立即购买"]')
+            if not buy_btn.exists:
+                buy_btn = self.d.xpath('//*[@text="领券购买"]')
+            
+            if buy_btn.exists:
+                buy_btn.click()
+                time.sleep(self.SLEEP_CLICK)
+                
+                if current_price is not None:
+                    str_price = str(current_price)
+                    if str_price.endswith('.0'):
+                        str_price = str_price[:-2]
+                    
+                    # 尝试两种不同的 UI 结构来匹配规格名称
+                    xpath1 = f'//android.widget.TextView[contains(@text, "{str_price}")]/preceding-sibling::android.widget.TextView[1]'
+                    xpath2 = f'//android.widget.TextView[contains(@text, "{str_price}")]/parent::*/preceding-sibling::android.widget.TextView[1]'
+                    
+                    if self.d.xpath(xpath1).exists:
+                        spec = self.d.xpath(xpath1).get_text()
+                    elif self.d.xpath(xpath2).exists:
+                        spec = self.d.xpath(xpath2).get_text()
+                
+                if not spec:
+                    # 兜底:尝试常见的规格前缀(比如“容量”、“颜色”、“规格”等)
+                    fallback_labels = ["规格", "容量", "颜色", "尺码", "款式", "净含量", "类型", "套餐", "版本", "型号"]
+                    for label in fallback_labels:
+                        fallback_xpath = f'//android.widget.TextView[@text="{label}"]/parent::*/following-sibling::*//android.widget.TextView[1]'
+                        if self.d.xpath(fallback_xpath).exists:
+                            spec = self.d.xpath(fallback_xpath).get_text()
+                            if spec:
+                                break
+                
+                # 无论成败,提取完都必须把弹窗关掉,以便后续抓取店铺
+                self.d.press("back")
+                time.sleep(self.get_sleep_time())
+
+            if spec:
+                print(f"  规格={spec}")
+            else:
+                print("  [失败] 未找到规格,留空")
+        except Exception as e:
+            print(f"  规格提取异常: {e}")
+            spec = ""
+            try:
+                self.d.press("back")
+                time.sleep(self.get_sleep_time())
+            except:
+                pass
+        return spec or ""
+
+    # ============================================================
+    # 4. 提取店铺名(拿不到返回 "")
+    # ============================================================
+
+    def get_shop_name(self):
+        print("\n[4/7] 提取店铺名...")
+        self._dismiss_popup_if_exists()
+        try:
+            self._scroll_until_found('//*[@text="进店"]', max_swipes=5)
+            
+            # 尝试找 "进店" 按钮前面的文本节点作为店铺名
+            shop_name_node = self.d.xpath('//*[@text="进店"]/preceding-sibling::android.widget.TextView[1]')
+            if not shop_name_node.exists:
+                shop_name_node = self.d.xpath('//*[@text="进店"]/../preceding-sibling::*//android.widget.TextView[1]')
+                
+            shop_name = ""
+            if shop_name_node.exists:
+                shop_name = shop_name_node.get_text()
+                
+            # 如果没拿到,用关键字回退匹配
+            if not shop_name:
+                for keyword in ["旗舰店", "大药房", "专卖店", "专营店", "店"]:
+                    shop_name = self._safe_get_attr(f'//*[contains(@content-desc, "{keyword}")]', "content_desc")
+                    if not shop_name:
+                        shop_name = self._safe_get_attr(f'//*[contains(@text, "{keyword}")]')
+                    if shop_name:
+                        break
+
+            if shop_name:
+                print(f"  店铺名={shop_name}")
+            else:
+                print("  [失败] 未找到店铺名,留空")
+        except Exception as e:
+            print(f"  店铺名提取异常: {e}")
+            shop_name = ""
+        return shop_name or ""
+
+    # ============================================================
+    # 5. 提取公司名(拿不到返回 "")
+    # ============================================================
+
+    def _find_shop_avatar(self):
+        imgs = self.d.xpath('//android.widget.ImageView').all()
+        for img in imgs:
+            try:
+                bounds = img.info.get('bounds', {})
+                left = bounds.get('left', 0)
+                top = bounds.get('top', 0)
+                right = bounds.get('right', 0)
+                bottom = bounds.get('bottom', 0)
+                w = right - left
+                h = bottom - top
+                if 30 <= left <= 120 and 300 <= top <= 370 and abs(w - h) <= 30:
+                    return img
+            except Exception:
+                continue
+        return None
+
+    def get_company_name(self):
+        print("\n[5/7] 提取公司名...")
+        self._dismiss_popup_if_exists()
+        company = ""
+        try:
+            enter_btn = self._scroll_until_found('//*[@text="进店"]', max_swipes=5)
+            if not enter_btn:
+                print("  [失败] 未找到「进店」,公司名留空")
+                return ""
+
+            enter_btn.click()
+            time.sleep(random.uniform(1.5, 2))
+            print("  点击「进店」")
+            self._dismiss_popup_if_exists()
+
+            avatar = self._find_shop_avatar()
+            if avatar:
+                avatar.click()
+                time.sleep(random.uniform(1.5, 2))
+                print("  点击「店铺头像」")
+            else:
+                notes_btn = self.d.xpath('//*[contains(@text, "篇笔记")]')
+                if not notes_btn.exists:
+                    print("  [失败] 未找到店铺头像和「X篇笔记」,公司名留空")
+                    self.d.press("back")
+                    time.sleep(self.get_sleep_time())
+                    return ""
+                notes_btn.click()
+                time.sleep(random.uniform(1.5, 2))
+                print("  点击「X篇笔记」资质入口")
+
+            self._dismiss_popup_if_exists()
+            time.sleep(self.get_sleep_time())
+            company = self._safe_get_attr('//*[contains(@text, "公司")]')
+            if company:
+                print(f"  公司名={company}")
+            else:
+                print("  [无] 未找到公司名,留空")
+
+            self.d.press("back")
+            time.sleep(self.get_sleep_time())
+            self._dismiss_popup_if_exists()
+            self.d.press("back")
+            time.sleep(self.get_sleep_time())
+            self._dismiss_popup_if_exists()
+        except Exception as e:
+            print(f"  公司名提取异常: {e}")
+            company = ""
+        return company or ""
+
+    # ============================================================
+    # 6. 提取商品链接(拿不到返回 "")
+    # ============================================================
+
+    def get_product_link(self):
+        print("\n[6/7] 提取商品链接...")
+        self._dismiss_popup_if_exists()
+        try:
+            # 获取老的剪贴板
+            old_clipboard = (self.d.clipboard or "").strip()
+
+            share_btn = self.d.xpath('//*[@content-desc="分享商品"]')
+            if not share_btn.exists:
+                share_btn = self.d.xpath('//*[contains(@content-desc, "分享")]')
+                
+            # 增加一个重试逻辑,防止页面卡顿导致还没加载出来
+            if not share_btn.exists:
+                time.sleep(2)
+                share_btn = self.d.xpath('//*[contains(@content-desc, "分享")]')
+
+            if not share_btn.exists:
+                print("  [失败] 未找到「分享商品」,链接留空")
+                return ""
+
+            share_btn.click()
+            time.sleep(1)
+            print("  点击「分享商品」")
+
+            copy_btn = self.d.xpath('//*[@text="复制链接"]')
+            if copy_btn.exists:
+                copy_btn.click()
+                time.sleep(self.SLEEP_CLIPBOARD)
+
+            link = (self.d.clipboard or "").strip()
+            
+            if link == old_clipboard and link != "":
+                print("  [警告] 剪贴板内容未改变,可能复制失败")
+                link = ""
+
+            if link:
+                match = re.search(r'https?://xhslink\.com/\S+', link)
+                if match:
+                    link = match.group(0)
+                print(f"  商品链接={link}")
+            else:
+                print("  [失败] 剪贴板未更新,链接留空")
+        except Exception as e:
+            print(f"  链接提取异常: {e}")
+            link = ""
+        return link or ""
+
+    # ============================================================
+    # 7. 提取批准文号(拿不到返回 "")
+    # ============================================================
+
+    def get_approval_number(self):
+        print("\n[7/7] 提取批准文号...")
+        self._dismiss_popup_if_exists()
+        try:
+            w, h = self.d.window_size()
+            for _ in range(6):
+                self.d.swipe(w // 2, int(h * 0.88), w // 2, int(h * 0.50), duration=0.3)
+                time.sleep(self.get_sleep_time())
+                if self.d.xpath('//*[@text="批准文号"]').exists:
+                    break
+
+            approval = self._safe_get_attr('//*[starts-with(@text, "国药准字")]')
+            if approval:
+                print(f"  批准文号={approval}")
+            else:
+                print("  [无] 未找到国药准字,留空")
+        except Exception as e:
+            print(f"  批准文号提取异常: {e}")
+            approval = ""
+        return approval or ""
+
+    # ============================================================
+    # 去重 & 落库
+    # ============================================================
+
+    def data_is_exists(self, data):
+        required_keys = ['min_price', 'shop', 'scrape_date', 'platform']
+        if not all(key in data for key in required_keys):
+            return False
+        conn = None
+        try:
+            conn = get_mysql()
+            with conn.cursor() as cur:
+                query_sql = f"""
+                    SELECT * FROM {self.table_name}
+                    WHERE min_price = %s
+                    AND store_name = %s
+                    AND scrape_date = %s
+                    AND platform_id = %s
+                    AND collect_equipment_account_id = %s
+                    AND collect_round = %s
+                """
+                cur.execute(query_sql, (
+                    data['min_price'], data['shop'], data['scrape_date'],
+                    data['platform'],
+                    data.get('collect_equipment_account_id', 0),
+                    data.get('collect_round', 0),
+                ))
+                return bool(cur.fetchone())
+        except Exception as e:
+            print(f"去重查询异常: {e}")
+            return False
+        finally:
+            if conn:
+                conn.close()
+
+    def shop_is_exists(self, data):
+        shop_value = str(data.get('shop') or '').strip()
+        if not shop_value:
+            return False
+        conn = None
+        try:
+            conn = get_mysql()
+            with conn.cursor() as cur:
+                cur.execute(
+                    f"SELECT * FROM {self.shop_table_name} WHERE shop = %s LIMIT 1",
+                    (shop_value,)
+                )
+                return bool(cur.fetchone())
+        except Exception as e:
+            print(f"店铺去重查询异常: {e}")
+            return False
+        finally:
+            if conn:
+                conn.close()
+
+    def get_province_city_from_shop(self, data):
+        shop_name = data.get('shop')
+        if not shop_name:
+            return
+        conn = None
+        try:
+            conn = get_mysql()
+            with conn.cursor() as cur:
+                cur.execute(
+                    f"SELECT province, city, business_license_company "
+                    f"FROM {self.shop_table_name} WHERE shop = %s AND platform = %s LIMIT 1",
+                    (shop_name, XHS_PLATFORM_ID)
+                )
+                result = cur.fetchone()
+                if result:
+                    data['province_name'] = result[0] or ''
+                    data['city_name'] = result[1] or ''
+                    data['company_name'] = result[2] or ''
+                else:
+                    data['province_name'] = ''
+                    data['city_name'] = ''
+                    data['company_name'] = ''
+        except Exception as e:
+            print(f"查询省市信息失败: {e}")
+            data['province_name'] = ''
+            data['city_name'] = ''
+            data['company_name'] = ''
+        finally:
+            if conn:
+                conn.close()
+
+    def save_to_shop_database(self, data):
+        shop = str(data.get('shop') or '').strip()
+        if not shop:
+            print("保存店铺数据失败:shop 为空")
+            return False
+
+        now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+        fields = (
+            shop,  # company_id
+            shop,  # shop_id
+            str(data.get('contact_address') or '').strip(),
+            str(data.get('qualification_number') or '').strip(),
+            str(data.get('business_license_company') or '').strip(),
+            str(data.get('business_license_address') or '').strip(),
+            str(data.get('store_url') or '').strip(),
+            str(data.get('scrape_date') or self.get_current_date()).strip(),
+            str(data.get('platform') or str(XHS_PLATFORM_ID)).strip(),
+            str(data.get('province_name') or data.get('province') or '').strip(),
+            str(data.get('city_name') or data.get('city') or '').strip(),
+            data.get('create_time') or now,
+            data.get('update_time') or now,
+        )
+
+        for attempt in range(5):
+            conn = None
+            try:
+                conn = get_mysql()
+                with conn.cursor() as cur:
+                    cur.execute(f"""
+                        INSERT IGNORE INTO {self.shop_table_name} (
+                            shop,company_id,shop_id,contact_address, qualification_number,
+                            business_license_company, business_license_address,
+                            shop_url, scrape_date, platform,
+                            province, city, create_time, update_time
+                        ) VALUES (%s,%s,%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+                    """, (shop,) + fields)
+                conn.commit()
+                print("店铺数据存入数据库成功")
+                return True
+            except Exception as e:
+                print(f'保存店铺数据库异常 (尝试 {attempt + 1}/5): {e}')
+                if conn:
+                    conn.rollback()
+                    conn.close()
+                if attempt == 4:
+                    return False
+                time.sleep(2)
+
+    def save_to_database(self, data):
+        """保存采集数据,拿不到的字段全部写空/0"""
+        print(f'保存数据到数据库:{data}')
+        now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+        for attempt in range(5):
+            conn = None
+            try:
+                conn = get_mysql()
+                with conn.cursor() as cur:
+                    cur.execute(f"""
+                        INSERT IGNORE INTO {self.table_name} (
+                            enterprise_id, platform_id, platform_item_id,
+                            province_id, city_id,
+                            province_name, city_name, area_info,
+                            product_brand, product_name, product_specs, search_name,
+                            collect_config_info, one_box_price,
+                            manufacture_date, expiry_date, manufacturer, approval_number,
+                            is_sold_out, online_posting_count, continuous_listing_count,
+                            link_url, store_name, store_url,
+                            shipment_province_id, shipment_province_name,
+                            shipment_city_id, shipment_city_name,
+                            company_name, qualification_number,
+                            scrape_date, min_price, number, sales, inventory,
+                            snapshot_url,
+                            collect_equipment_account_id, insert_time, update_time,
+                            collect_round, collect_region_id, task_id,
+                            shop_id, company_id
+                        ) 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, %s,%s,%s,%s, %s,
+                            %s,%s,%s, %s,%s,%s,%s,
+                            %s,%s
+                        )
+                    """, (
+                        data.get('enterprise_id', 0),
+                        data.get('platform_id', XHS_PLATFORM_ID),
+                        data.get('product_name', '')+data.get('store_name', '')+str(data.get('min_price', 0)),
+                        data.get('province_id', 0),
+                        data.get('city_id', 0),
+                        data.get('province_name', ''),
+                        data.get('city_name', ''),
+                        data.get('area_info', ''),
+                        data.get('product_brand', ''),
+                        data.get('product_name', ''),
+                        data.get('product_specs', ''),
+                        data.get('search_name', ''),
+                        data.get('collect_config_info', ''),
+                        data.get('one_box_price', 0),
+                        data.get('manufacture_date', ''),
+                        data.get('expiry_date', ''),
+                        data.get('manufacturer', ''),
+                        data.get('approval_number', ''),
+                        data.get('is_sold_out', 0),
+                        data.get('online_posting_count', 1),
+                        data.get('continuous_listing_count', 1),
+                        data.get('link_url', ''),
+                        data.get('store_name', ''),
+                        data.get('store_url', ''),
+                        data.get('shipment_province_id', 0),
+                        data.get('shipment_province_name', ''),
+                        data.get('shipment_city_id', 0),
+                        data.get('shipment_city_name', ''),
+                        data.get('company_name', ''),
+                        data.get('qualification_number', ''),
+                        data.get('scrape_date', ''),
+                        data.get('min_price', 0),
+                        data.get('number', 1),
+                        data.get('sales', ''),
+                        data.get('inventory', ''),
+                        data.get('snapshot_url', ''),
+                        data.get('collect_equipment_account_id', 0),
+                        data.get('insert_time', now),
+                        data.get('update_time', now),
+                        data.get('collect_round', 0),
+                        data.get('collect_region_id', 0),
+                        data.get('task_id') or 0,
+                        data.get('store_name', ''),
+                        data.get('store_name', ''),
+                    ))
+                conn.commit()
+                self.max_counts += 1
+                print(f"========== 设备 [{self.device_id}] 存入数据库成功,当前该设备总计采集数据: {self.max_counts} 条 ==========")
+                return True
+            except Exception as e:
+                print(f'保存数据库异常 (尝试 {attempt + 1}/5): {e}')
+                if conn:
+                    conn.rollback()
+                    conn.close()
+                if attempt == 4:
+                    print("达到最大重试次数,保存失败")
+                    return False
+                time.sleep(2)
+
+    # ============================================================
+    # 快照功能(滚动截图 → 拼接 → 压缩 → 上传 OSS)
+    # ============================================================
+
+    def screenshot_and_upload_oss(self, screenshot_desc='xhs_product_screenshot'):
+        """1.滚动截图 → 2.压缩 → 3.上传OSS → 4.清理本地,返回 OSS URL 或 None"""
+        timestamp = datetime.datetime.now().strftime('%Y%m%d%H%M%S')
+        unique_id = uuid.uuid4().hex[:8]
+        base_name = f"{screenshot_desc}_{self.device_id}_{timestamp}_{unique_id}"
+        scroll_path = f"./{base_name}_scroll.png"
+        compress_path = f"./{base_name}_compress.jpg"
+
+        if not hasattr(self, 'oss_bucket'):
+            self.oss_auth = oss2.Auth(
+                self.oss_config["access_key_id"],
+                self.oss_config["access_key_secret"]
+            )
+            self.oss_bucket = oss2.Bucket(
+                self.oss_auth,
+                self.oss_config["endpoint"],
+                self.oss_config["bucket_name"]
+            )
+
+        try:
+            screenshot_image = self._scroll_screenshot()
+            screenshot_image.save(scroll_path)
+            print(f"滚动截图完成: {scroll_path}")
+
+            if not self.compress_image(scroll_path, compress_path):
+                raise Exception("图片压缩失败")
+
+            oss_file_path = f"{self.oss_config['oss_prefix']}/{os.path.basename(compress_path)}"
+            self.oss_bucket.put_object_from_file(oss_file_path, compress_path)
+            oss_url = f"https://{self.oss_config['bucket_name']}.{self.oss_config['endpoint']}/{oss_file_path}"
+            print(f"OSS上传成功: {oss_url}")
+            return oss_url
+        except Exception as e:
+            print(f"快照/上传OSS失败: {e}")
+            return None
+        finally:
+            for f in [scroll_path, compress_path]:
+                if os.path.exists(f):
+                    try:
+                        os.remove(f)
+                    except Exception:
+                        pass
+
+    def _merge_screenshots(self, screens):
+        """垂直堆叠多张 PIL 截图"""
+        if len(screens) == 1:
+            return screens[0].convert('RGB')
+        rgb = [s.convert('RGB') for s in screens]
+        total_w = rgb[0].width
+        total_h = sum(s.height for s in rgb)
+        merged = Image.new('RGB', (total_w, total_h))
+        y = 0
+        for img in rgb:
+            merged.paste(img, (0, y))
+            y += img.height
+        return merged
+
+    def _scroll_screenshot(self, scroll_times=1):
+        """截首屏 → 滚动到「进店」→ 堆叠拼接"""
+        w, h = self.d.window_size()
+        screens = [self.d.screenshot()]
+
+        if self.d(textContains='进店').exists or self.d(textStartsWith='进店').exists:
+            return self._merge_screenshots(screens)
+
+        for i in range(scroll_times):
+            self.d.swipe(w // 2, int(h * 0.85), w // 2, int(h * 0.15),
+                         duration=random.uniform(0.8, 1.5))
+            time.sleep(random.uniform(2.0, 4.0))
+            screens.append(self.d.screenshot())
+            if self.d(textContains='进店').exists or self.d(textStartsWith='进店').exists:
+                break
+
+        return self._merge_screenshots(screens)
+
+    def compress_image(self, input_path, output_path, target_size_kb=100,
+                       max_quality=80, min_quality=20):
+        """cv2 压缩图片到 target_size_kb 以内"""
+        try:
+            img = cv2.imread(input_path)
+            if img is None:
+                return False
+            h, w = img.shape[:2]
+            img_rs = cv2.resize(img, (int(w * 0.7), int(h * 0.7)),
+                                interpolation=cv2.INTER_AREA)
+
+            quality = max_quality
+            while quality >= min_quality:
+                cv2.imwrite(output_path, img_rs, [cv2.IMWRITE_JPEG_QUALITY, quality])
+                if os.path.getsize(output_path) / 1024 <= target_size_kb:
+                    return True
+                quality -= 5
+
+            img_rs2 = cv2.resize(img_rs, (int(img_rs.shape[1] * 0.5),
+                                           int(img_rs.shape[0] * 0.5)),
+                                 cv2.INTER_AREA)
+            cv2.imwrite(output_path, img_rs2, [cv2.IMWRITE_JPEG_QUALITY, min_quality])
+            return True
+        except Exception as e:
+            print(f"图片压缩失败: {e}")
+            return False
+
+    # ============================================================
+
+    def is_max_count_reached(self):
+        return bool(self.max_counts_limit and self.max_counts >= self.max_counts_limit)
+
+    # ============================================================
+    # 完成上报
+    # ============================================================
+
+    def finish_task_normally(self, end_page, reason):
+        if not self.finish_reported:
+            report_api(
+                self.task_id,
+                platform=self.platform,
+                username=getattr(self, 'username', ''),
+                page=end_page,
+                is_finished=1,
+                crawled_count=getattr(self, 'max_counts', 0),
+                total_pages=end_page,
+            )
+            self.finish_reported = True
+        print(reason)
+        return True
+
+    def finish_task_abnormally(self, end_page, reason, finish_status=0):
+        if not self.finish_reported:
+            report_api(
+                self.task_id,
+                platform=self.platform,
+                username=getattr(self, 'username', ''),
+                page=end_page,
+                is_finished=0,
+                crawled_count=getattr(self, 'max_counts', 0),
+                total_pages=end_page,
+            )
+            self.finish_reported = True
+        print(reason)
+        return False
+
+    def finish_task_with_max_count(self, end_page):
+        return self.finish_task_normally(
+            end_page,
+            f"达到最大采集数量 {self.max_counts_limit},已采集 {self.max_counts} 条,停止任务"
+        )
+
+    # ============================================================
+    # 单商品详情采集
+    # ============================================================
+
+    def integrate_data(self):
+        """采集单个商品详情页所有数据并落库"""
+        print(self.SEP)
+        print(f"  小红书商品详情页采集")
+        print(f"  时间: {self.get_current_date()}")
+        print(f"  设备: {self.device_id}")
+        print(f"  目标: {self.title_key}  |  品牌: {self.brand or '-'}")
+        print(self.SEP)
+
+        title = self.get_title() or ""
+        if title and not self.is_title_useful(title):
+            print(f"    [跳过] 标题不匹配: {title[:40]}...")
+            self.unrelated_data += 1
+            return False
+
+        # 快照:刚进详情页,页面最干净,截取商品顶部→店铺区域
+        oss_url = ''
+        try:
+            result = self.screenshot_and_upload_oss()
+            if result:
+                oss_url = result
+                print(f"快照上传成功: {oss_url}")
+        except Exception as e:
+            print(f"快照异常: {e}")
+
+        min_price = self.get_price()
+        spec = self.get_spec(min_price) or ""
+        shop_name = self.get_shop_name() or ""
+        company = self.get_company_name() or ""
+        
+        link = self.get_product_link() or ""
+        if not link:
+            print("    [跳过] 未获取到商品链接,本条数据作废,不存入数据库")
+            return False
+            
+        approval = self.get_approval_number() or ""
+
+        print(f"\n{self.SEP}")
+        print("  采集结果汇总")
+        print(self.SEP)
+        print(f"  标题:       {title}")
+        print(f"  价格:       {min_price} 元" if min_price is not None else "  价格:       (未取到)")
+        print(f"  规格:       {spec}")
+        print(f"  店铺名:     {shop_name}")
+        print(f"  公司名:     {company}")
+        print(f"  批准文号:   {approval}")
+        print(f"  商品链接:   {link}")
+        print(self.SEP)
+
+        scrape_date = self.get_current_date()
+
+        # 去重
+        dup_data = {
+            'min_price': min_price or 0,
+            'shop': shop_name or '',
+            'scrape_date': scrape_date,
+            'platform': str(XHS_PLATFORM_ID),
+            'collect_equipment_account_id': self.collect_equipment_account_id or 0,
+            'collect_round': self.collect_round or 0,
+        }
+        if min_price is not None and self.data_is_exists(dup_data):
+            print('存在相同数据不入库')
+            return True
+
+        # 店铺表
+        province_name = ''
+        city_name = ''
+        company_name = company or ''
+
+        shop_data = {
+            'shop': shop_name,
+            'store_url': link,
+            'scrape_date': scrape_date,
+            'platform': XHS_PLATFORM_ID,
+            'create_time': datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+            'update_time': datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+        }
+
+        if shop_name and self.shop_is_exists(shop_data):
+            print("店铺数据已存在,进行省市回填")
+            self.get_province_city_from_shop(shop_data)
+            province_name = shop_data.get('province_name') or ''
+            city_name = shop_data.get('city_name') or ''
+            # 如果数据库里的为空,则保留本次新抓到的 company_name
+            company_name = shop_data.get('company_name') or company_name
+            
+            # 如果之前入库时省市是空的,但我们现在有公司名,就顺手解析一次
+            if company_name and not province_name:
+                province_name, city_name = self.infer_region_from_address(company_name)
+                print(f"基于公司名补全省市: {province_name}, {city_name}")
+
+        elif shop_name:
+            print("店铺数据不存在,插入店铺表")
+            shop_data['business_license_company'] = company_name
+            if company_name:
+                p_name, c_name = self.infer_region_from_address(company_name)
+                shop_data['province_name'] = p_name
+                shop_data['city_name'] = c_name
+                province_name = p_name
+                city_name = c_name
+                print(f"基于公司名匹配到省市: {p_name}, {c_name}")
+            self.save_to_shop_database(shop_data)
+
+        # 获取省市ID
+        province_id, city_id = self._resolve_region_ids(province_name, city_name)
+
+        # 组装落库数据
+        now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+        save_data = {
+            'enterprise_id': self.enterprise_id or 0,
+            'platform_id': XHS_PLATFORM_ID,
+            'platform_item_id': '',
+            'province_id': province_id,
+            'city_id': city_id,
+            'province_name': province_name,
+            'city_name': city_name,
+            'area_info': '',
+            'product_brand': self.brand,
+            'product_name': title,
+            'product_specs': spec,
+            'search_name': self.search_key,
+            'collect_config_info': self.collect_config_info or '',
+            'one_box_price': 0,
+            'manufacture_date': '',
+            'expiry_date': '',
+            'manufacturer': '',
+            'approval_number': approval,
+            'is_sold_out': 0,
+            'online_posting_count': 1,
+            'continuous_listing_count': 1,
+            'link_url': link,
+            'store_name': shop_name,
+            'store_url': '',
+            'shipment_province_id': 0,
+            'shipment_province_name': '',
+            'shipment_city_id': 0,
+            'shipment_city_name': '',
+            'company_name': company_name,
+            'qualification_number': '',
+            'scrape_date': scrape_date,
+            'min_price': min_price or 0,
+            'number': 1,
+            'sales': '',
+            'inventory': '',
+            'snapshot_url': oss_url or '',
+            'collect_equipment_account_id': self.collect_equipment_account_id or 0,
+            'insert_time': now,
+            'update_time': now,
+            'collect_round': self.collect_round or 0,
+            'collect_region_id': self.collect_region_id or 0,
+            'task_id': self.task_id or 0,
+        }
+
+        try:
+            from spiders.pdd.box_script import extract_quantity_and_unit
+            quantity, unit = extract_quantity_and_unit(f"{title} {spec}")
+            if quantity:
+                save_data['number'] = quantity
+                if not save_data['product_specs']:
+                    save_data['product_specs'] = f"{quantity}{unit}"
+                print(f"✅ 智能提取到数量: {quantity}{unit}")
+        except Exception as e:
+            print(f"智能提取数量失败: {e}")
+
+        self.save_to_database(save_data)
+        return True
+
+    # ============================================================
+    # 首页检测 & 回退(参照 PDD 的 is_pdd_home_page / back_to_pdd_home_page)
+    # ============================================================
+
+    XHS_HOME_ANCHORS = [
+        '//*[@text="首页"]',
+        '//*[@text="市集"]',
+        '//*[@content-desc="发布"]',
+        '//*[@text="消息"]',
+        '//*[@text="我"]',
+    ]
+    XHS_HOME_HIT_THRESHOLD = 3
+
+    def is_xhs_home_page(self):
+        """命中 >= 3 个底部 tab 锚点即认为在首页"""
+        try:
+            hits = sum(1 for xp in self.XHS_HOME_ANCHORS if self.d.xpath(xp).exists)
+            return hits >= self.XHS_HOME_HIT_THRESHOLD
+        except Exception:
+            return False
+
+    def is_xhs_foreground(self):
+        """前台包名 = 小红书 + 页面可见"""
+        try:
+            current_app = self.d.app_current() or {}
+            if current_app.get("package", "") != self.package_name:
+                return False
+            screen_on = bool((self.d.info or {}).get("screenOn", True))
+            if not screen_on:
+                return False
+            # 任一已知锚点可见就算在可操作页面
+            visible_anchors = self.XHS_HOME_ANCHORS + [
+                '//*[@text="进店"]',
+                '//*[@text="搜索"]',
+                '//*[contains(@content-desc, "商品名称")]',
+            ]
+            for xp in visible_anchors:
+                if self.d.xpath(xp).exists:
+                    return True
+            return False
+        except Exception:
+            return False
+
+    def back_to_xhs_home_page(self, max_back_times=5):
+        """按 back 直到回到小红书首页,最多 max_back_times 次"""
+        for i in range(max_back_times):
+            if self.is_xhs_home_page():
+                print(f"  已回到小红书首页(第 {i} 次 back)")
+                return True
+            self.d.press("back")
+            time.sleep(self.get_sleep_time())
+        return self.is_xhs_home_page()
+
+    def is_xhs_package_foreground(self):
+        """只判断前台包名是否为小红书,不检查页面锚点"""
+        try:
+            return (self.d.app_current() or {}).get("package", "") == self.package_name
+        except Exception:
+            return False
+
+    # ============================================================
+    # 主循环
+    # ============================================================
+
+    def main(self):
+        completed_normally = False
+        current_page = self.page
+        consecutive_empty_pages = 0
+
+        if not self.connect_device():
+            self.finish_task_abnormally(current_page, "设备连接失败")
+            return False
+
+        # 统一入口准备:确保在首页后再走市集→搜索
+        if self.is_xhs_package_foreground():
+            print("小红书在前台,尝试回退到首页...")
+            self.back_to_xhs_home_page(max_back_times=5)
+        else:
+            print("小红书不在前台,重新启动...")
+            self.start_app()
+            time.sleep(5)  # 给 App 充足加载时间
+            self.back_to_xhs_home_page(max_back_times=3)
+
+        # 最终确认:如果还是不在首页,再启动一次
+        if not self.is_xhs_home_page():
+            print("仍未在首页,重新启动再试...")
+            self.start_app()
+            time.sleep(5)
+            self.back_to_xhs_home_page(max_back_times=3)
+
+        if not self.enter_search_page():
+            self.finish_task_abnormally(current_page, "进入搜索页失败")
+            return False
+
+        report_api(self.task_id, platform=self.platform, username=getattr(self, 'username', ''),
+                   page=self.page, is_finished=0)
+
+        self._heartbeat_running = True
+
+        def heartbeat_worker():
+            while self._heartbeat_running:
+                time.sleep(HEARTBEAT_INTERVAL_SECONDS)
+                if self._heartbeat_running:
+                    report_heartbeat(self.task_id, getattr(self, 'page', 0), device_id=self.device_id)
+
+        threading.Thread(target=heartbeat_worker, daemon=True).start()
+
+        try:
+            for _ in range(300):
+                print(f"\n{'=' * 60}")
+                print(f"  第 {current_page + 1} 页")
+                print(f"{'=' * 60}")
+
+                if self.unrelated_data > 15:
+                    completed_normally = self.finish_task_normally(
+                        current_page, '连续超过15个不达标,结束采集')
+                    break
+
+                if self.is_max_count_reached():
+                    completed_normally = self.finish_task_with_max_count(current_page)
+                    break
+
+                cards = self.get_product_cards()
+                print(f"  当前页 {len(cards)} 个可见商品")
+
+                if not cards:
+                    consecutive_empty_pages += 1
+                    if consecutive_empty_pages >= 3:
+                        completed_normally = self.finish_task_normally(
+                            current_page, "连续空页达到阈值")
+                        break
+                else:
+                    consecutive_empty_pages = 0
+
+                for i, card in enumerate(cards):
+                    if self.unrelated_data > 15 or self.is_max_count_reached():
+                        break
+
+                    print(f"\n  [{i + 1}/{len(cards)}]")
+                    try:
+                        card.click()
+                    except Exception as e:
+                        print(f"    [失败] 点击失败: {e}")
+                        continue
+                    time.sleep(self.SLEEP_CLICK)
+                    self._dismiss_popup_if_exists()
+
+                    if self.is_on_list_page():
+                        print("    [跳过] 点击后仍在列表页")
+                        continue
+
+                    try:
+                        self.integrate_data()
+                    except Exception as e:
+                        print(f"    [异常] 采集详情失败: {e}")
+
+                    if not self.back_to_list_page():
+                        print("    [警告] 未能回到列表页")
+                        time.sleep(self.get_sleep_time())
+                    time.sleep(self.get_sleep_time())
+
+                if self.end_page and current_page >= self.end_page:
+                    completed_normally = self.finish_task_normally(
+                        current_page, f"已到结束页 {self.end_page}")
+                    break
+
+                print(f"\n  翻到第 {current_page + 2} 页...")
+                self.d.swipe_ext("up", scale=0.5)
+                time.sleep(self.get_sleep_time())
+                current_page += 1
+                self.page = current_page
+                
+                # 同步实时状态给后端,更新 UI 上的页数和已采集数量
+                report_api(self.task_id, platform=self.platform, username=getattr(self, 'username', ''), page=self.page, is_finished=0, crawled_count=self.max_counts)
+
+        finally:
+            self._heartbeat_running = False
+
+        if completed_normally is None:
+            completed_normally = False
+        if not completed_normally and not self.finish_reported:
+            self.finish_task_abnormally(current_page, "采集流程异常结束")
+
+        print(f"\n  遍历完成,共处理 {current_page + 1} 页,采集 {self.max_counts} 条")
+        return completed_normally
+
+
+# ============================================================
+# 启动
+# ============================================================
+
+def main():
+    """启动调度器入口(模仿 PDD 模式)。"""
+    interval_seconds = MANUAL_SCHEDULER_INTERVAL_SECONDS if USE_MANUAL_TASKS else SCHEDULER_INTERVAL_SECONDS
+    logging.info(f"XHS 调度器启动,轮询间隔 {interval_seconds} 秒")
+    dispatch_pending_tasks()
+    if USE_MANUAL_TASKS:
+        logging.info(f"手动任务模式:启动定时轮询,间隔 {MANUAL_SCHEDULER_INTERVAL_SECONDS} 秒")
+        if all_manual_tasks_dispatched() and not has_active_workers():
+            logging.info("手动任务模式:全部任务已完成,进程退出")
+            return
+        schedule_dispatch(MANUAL_SCHEDULER_INTERVAL_SECONDS, manual_scheduled_dispatch_job)
+        scheduler_stop_event.wait()
+        return
+    schedule_dispatch(SCHEDULER_INTERVAL_SECONDS)
+    scheduler_stop_event.wait()
+
+
+if __name__ == '__main__':
+    main()

+ 729 - 0
xhs/spiders/xhs/product_scraper.py

@@ -0,0 +1,729 @@
+# coding=utf-8
+"""
+小红书商品详情页采集
+基于 uiautomator2 真机自动化方案
+
+使用方法:
+  1. 填好下方变量区
+  2. 手机打开小红书, 搜索目标关键词, 进入商品列表页
+  3. python product_scraper.py
+"""
+
+import uiautomator2 as u2
+import time
+import re
+import random
+import subprocess
+import datetime
+
+
+# ============================================================
+# ====== 变量区 ======
+# ============================================================
+
+DEVICE_ID = "XOYPOZDADU79VGVG"
+XHS_PACKAGE = "com.xingin.xhs"
+PRODUCT_NAME = "小儿感冒颗粒"
+BRAND = "999"
+SPEC_LIST = '24'
+
+# ============================================================
+# ====== 变量区结束 ======
+# ============================================================
+
+
+class XHS:
+    """小红书商品详情页采集器"""
+
+    # ---- 类常量 ----
+    SEP = "=" * 60
+    SLEEP_CLICK = 1.5       # 点击后等待页面响应
+    SLEEP_APP_START = 3     # App 启动等待
+    SLEEP_CLIPBOARD = 0.8   # 复制链接等待
+
+    # 弹窗检测:命中 >= POPUP_THRESHOLD 个 xpath 即认定为弹窗
+    POPUP_XPATHS = [
+        '//*[@text="立即领取"]',
+        '//*[@text="送你 66周年庆礼券 啦 "]',
+        '//*[contains(@text, "可用")]',   # 匹配 "满150可用" 等变体
+    ]
+    POPUP_THRESHOLD = 2
+
+    # 列表页判定锚点(命中 >= LIST_PAGE_HIT_THRESHOLD 个即认为在列表页)
+    LIST_PAGE_ANCHORS = [
+        '//*[@text="搜索"]',
+        '//*[@text="全部"]',
+        '//*[@text="用户"]',
+        '//*[@text="商品"]',
+        '//*[@content-desc="全部删除"]',
+    ]
+    LIST_PAGE_HIT_THRESHOLD = 2
+
+    def __init__(self, device_id, product_name="", brand="", spec_list=None):
+        self.package_name = XHS_PACKAGE
+        self.device_id = device_id
+        self.d = None
+        self.product_name = str(product_name or "").strip()
+        self.brand = str(brand or "").strip()
+        self.spec_list = self._normalize_rule_list(spec_list)
+
+    # ============================================================
+    # 工具方法
+    # ============================================================
+
+    @staticmethod
+    def _normalize_rule_list(value):
+        if value is None:
+            return []
+        if isinstance(value, (list, tuple, set)):
+            raw_values = value
+        else:
+            raw_values = [value]
+        return [str(v).strip() for v in raw_values if str(v).strip()]
+
+    @staticmethod
+    def _normalize_match_text(value):
+        return re.sub(r'\s+', '', str(value or '')).lower()
+
+    @staticmethod
+    def get_sleep_time():
+        return random.uniform(0.5, 1.0)
+
+    @staticmethod
+    def get_current_date():
+        return datetime.datetime.now().strftime('%Y/%m/%d')
+
+    @staticmethod
+    def _strip_content_desc_prefix(text, prefix):
+        """去掉 content-desc 中的前缀标签(兼容中英文逗号)"""
+        for sep in (",", ","):
+            text = text.replace(f"{prefix}{sep}", "")
+        return text.strip()
+
+    def _safe_get_attr(self, xpath, attr="text"):
+        """安全获取元素属性(text 或 content_desc),避免元素缺失导致崩溃"""
+        try:
+            el = self.d.xpath(xpath)
+            if not el.exists:
+                return ""
+            if attr == "content_desc":
+                return (el.info.get('contentDescription') or "").strip()
+            return (el.text or "").strip()
+        except Exception:
+            return ""
+
+    def _scroll_until_found(self, xpath, max_swipes=5, direction="up", scale=0.3):
+        """翻页直到找到目标元素,返回元素对象;找不到返回 None"""
+        el = self.d.xpath(xpath)
+        if el.exists:
+            return el
+        for _ in range(max_swipes):
+            self.d.swipe_ext(direction, scale=scale)
+            time.sleep(self.get_sleep_time())
+            el = self.d.xpath(xpath)
+            if el.exists:
+                return el
+        return None
+
+    def _count_popup_elements(self):
+        """统计当前页面命中弹窗特征 xpath 的数量"""
+        count = 0
+        for xpath in self.POPUP_XPATHS:
+            if self.d.xpath(xpath).exists:
+                count += 1
+        return count
+
+    def _dismiss_popup_if_exists(self):
+        """检测弹窗,命中 >= 阈值则按 back 关闭,返回是否关闭"""
+        count = self._count_popup_elements()
+        if count >= self.POPUP_THRESHOLD:
+            print(f"  [弹窗检测] 命中 {count} 个特征,关闭弹窗")
+            self.d.press("back")
+            time.sleep(self.get_sleep_time())
+            return True
+        return False
+
+    def _find_with_popup_retry(self, find_func, step_name="", max_retries=2):
+        """查找元素,失败则关闭弹窗后重试;返回找到的元素或 None"""
+        for attempt in range(max_retries):
+            result = find_func()
+            if result:
+                return result
+            if attempt < max_retries - 1:
+                print(f"  [{step_name}] 未找到,关闭弹窗后重试 ({attempt + 1}/{max_retries - 1})")
+                self._dismiss_popup_if_exists()
+                time.sleep(random.uniform(1.5, 2))
+        return None
+
+    @staticmethod
+    def _match_any_keyword(text, keywords):
+        """判断 text 是否命中 keywords 中的任一关键词;keywords 为空则放行"""
+        keyword_list = XHS._normalize_rule_list(keywords)
+        if not keyword_list:
+            return True
+        normalized = XHS._normalize_match_text(text)
+        return any(XHS._normalize_match_text(k) in normalized for k in keyword_list)
+
+    def is_on_list_page(self):
+        """判断当前是否在商品列表页"""
+        hits = sum(1 for xp in self.LIST_PAGE_ANCHORS if self.d.xpath(xp).exists)
+        return hits >= self.LIST_PAGE_HIT_THRESHOLD
+
+    def is_title_useful(self, title):
+        """标题需同时包含产品名、品牌、规格(各自命中一个即可),打印不匹配原因"""
+        if self.product_name and not self._match_any_keyword(title, self.product_name):
+            print(f"    不匹配: 产品名「{self.product_name}」")
+            return False
+        if self.brand and not self._match_any_keyword(title, self.brand):
+            print(f"    不匹配: 品牌「{self.brand}」")
+            return False
+        if self.spec_list and not self._match_any_keyword(title, self.spec_list):
+            print(f"    不匹配: 规格「{self.spec_list}」")
+            return False
+        return True
+
+    def get_product_cards(self):
+        """获取当前屏幕可见的商品卡片(按子元素高度定位正确的 RecyclerView)"""
+        cards = []
+        for idx in (1, 2, 3):
+            candidates = self.d.xpath(
+                f'(//androidx.recyclerview.widget.RecyclerView)[{idx}]/android.widget.FrameLayout'
+            ).all()
+            for c in candidates:
+                try:
+                    bounds = c.info.get('bounds', {})
+                    h = bounds.get('bottom', 0) - bounds.get('top', 0)
+                    if h > 400:  # 商品卡片高 600+,tab 栏只有 ~140
+                        cards = candidates
+                        break
+                except Exception:
+                    continue
+            if cards:
+                break
+
+        if not cards:
+            return []
+        visible = []
+        for card in cards:
+            try:
+                bounds = card.info.get('bounds', {})
+                top = bounds.get('top', 0)
+                bottom = bounds.get('bottom', 0)
+                h = bottom - top
+                if h > 400 and top >= 554 and bottom <= 2600:
+                    visible.append(card)
+            except Exception:
+                continue
+        return visible
+
+    def back_to_list_page(self, max_attempts=5):
+        """按 back 直到回到列表页"""
+        for _ in range(max_attempts):
+            if self.is_on_list_page():
+                return True
+            self.d.press("back")
+            time.sleep(self.get_sleep_time())
+        return self.is_on_list_page()
+
+    def _find_shop_avatar(self):
+        """在店铺页按位置范围找店铺头像 ImageView"""
+        imgs = self.d.xpath('//android.widget.ImageView').all()
+        for img in imgs:
+            try:
+                bounds = img.info.get('bounds', {})
+                left = bounds.get('left', 0)
+                top = bounds.get('top', 0)
+                right = bounds.get('right', 0)
+                bottom = bounds.get('bottom', 0)
+                w = right - left
+                h = bottom - top
+                if 30 <= left <= 120 and 300 <= top <= 370 and abs(w - h) <= 30:
+                    return img
+            except Exception:
+                continue
+        return None
+
+    # ============================================================
+    # 设备连接 & App 控制
+    # ============================================================
+
+    def connect_device(self):
+        try:
+            self.d = u2.connect_usb(self.device_id)
+            self._restart_uiautomator_services()
+            print(f'[连接成功] 设备: {self.device_id}')
+            return True
+        except Exception as e:
+            print(f'[连接失败] {self.device_id}: {e}')
+            return False
+
+    def _restart_uiautomator_services(self):
+        stop_cmd = f'adb -s {self.device_id} shell /data/local/tmp/atx-agent server -d --stop'
+        start_cmd = f'adb -s {self.device_id} shell /data/local/tmp/atx-agent server -d'
+        subprocess.run(stop_cmd, capture_output=True, text=True, shell=True)
+        time.sleep(self.get_sleep_time())
+        subprocess.run(start_cmd, capture_output=True, text=True, shell=True)
+        time.sleep(self.get_sleep_time())
+
+    def start_app(self):
+        self.d.app_start(self.package_name)
+        time.sleep(self.SLEEP_APP_START)
+
+    # ============================================================
+    # 1. 提取标题
+    # ============================================================
+
+    def get_title(self):
+        print("\n[1/7] 提取标题...")
+        # 优先从 content-desc 取(直接进入的详情页)
+        title = self._safe_get_attr(
+            '//*[contains(@content-desc, "商品名称")]', "content_desc"
+        )
+        if title:
+            title = self._strip_content_desc_prefix(title, "商品名称")
+            print(f"  标题={title}")
+            return title
+        # fallback: 取页面上最长的 TextView(搜索结果点进来的详情页)
+        try:
+            text_els = self.d.xpath('//android.widget.TextView').all()
+            longest = ""
+            for el in text_els:
+                t = (el.text or "").strip()
+                if len(t) > len(longest):
+                    longest = t
+            if longest and len(longest) > 5:
+                print(f"  标题(fallback)={longest}")
+                return longest
+        except Exception:
+            pass
+        print("  [失败] 未找到标题")
+        return ""
+
+    # ============================================================
+    # 2. 提取价格
+    # ============================================================
+
+    def get_price(self):
+        print("\n[2/7] 提取价格...")
+
+        buy_btn = self.d.xpath('//*[@text="立即购买"]')
+        if not buy_btn.exists:
+            buy_btn = self.d.xpath('//*[@text="领券购买"]')
+        if not buy_btn.exists:
+            print("  [失败] 未找到「购买选项」")
+            return None
+
+        buy_btn.click()
+        print("  点击「购买」")
+        time.sleep(self.SLEEP_CLICK)
+
+        # 优先到手价,兜底 ¥
+        price_str = ""
+        price_xpath = '//*[contains(@content-desc, "到手价")]'
+        if self.d.xpath(price_xpath).exists:
+            price_str = self._safe_get_attr(price_xpath, "content_desc")
+            print(f"  [到手价] {price_str}")
+        else:
+            fallback_xpath = '//*[contains(@content-desc, "¥")]'
+            if self.d.xpath(fallback_xpath).exists:
+                price_str = self._safe_get_attr(fallback_xpath, "content_desc")
+                print(f"  [兜底¥] {price_str}")
+
+        self.d.press("back")
+        time.sleep(self.get_sleep_time())
+
+        if not price_str:
+            return None
+
+        match = re.search(r'¥([\d\.]+)', price_str)
+        if match:
+            price = float(match.group(1))
+            print(f"  价格={price} 元")
+            return price
+
+        print(f"  [失败] 无法解析价格: {price_str}")
+        return None
+
+    # ============================================================
+    # 3. 提取规格
+    # ============================================================
+
+    def get_spec(self):
+        print("\n[3/7] 提取规格...")
+        spec = self._safe_get_attr(
+            '//*[contains(@content-desc, "已选规格")]', "content_desc"
+        )
+        if spec:
+            spec = self._strip_content_desc_prefix(spec, "已选规格")
+            print(f"  规格={spec}")
+        else:
+            print("  [失败] 未找到规格")
+        return spec
+
+    # ============================================================
+    # 4. 提取店铺名
+    # ============================================================
+
+    def get_shop_name(self):
+        print("\n[4/7] 提取店铺名...")
+
+        self._scroll_until_found('//*[@text="进店"]', max_swipes=5)
+
+        shop_name = self._safe_get_attr(
+            '//*[contains(@content-desc, "旗舰店")]', "content_desc"
+        )
+        if not shop_name:
+            shop_name = self._safe_get_attr('//*[contains(@text, "旗舰店")]')
+        if shop_name:
+            print(f"  店铺名={shop_name}")
+        else:
+            print("  [失败] 未找到店铺名")
+        return shop_name
+
+    # ============================================================
+    # 5. 提取公司名
+    # ============================================================
+
+    def get_company_name(self):
+        print("\n[5/7] 提取公司名...")
+
+        enter_btn = self._scroll_until_found('//*[@text="进店"]', max_swipes=5)
+        if not enter_btn:
+            print("  [失败] 未找到「进店」")
+            return ""
+
+        enter_btn.click()
+        time.sleep(random.uniform(1.5, 2))
+        print("  点击「进店」")
+        self._dismiss_popup_if_exists()
+
+        # 优先点头像进资质页,没有头像再找"X篇笔记"
+        avatar = self._find_shop_avatar()
+        if avatar:
+            avatar.click()
+            time.sleep(random.uniform(1.5, 2))
+            print("  点击「店铺头像」")
+        else:
+            notes_btn = self.d.xpath('//*[contains(@text, "篇笔记")]')
+            if not notes_btn.exists:
+                print("  [失败] 未找到店铺头像和「X篇笔记」")
+                self.d.press("back")
+                time.sleep(self.get_sleep_time())
+                return ""
+            notes_btn.click()
+            time.sleep(random.uniform(1.5, 2))
+            print("  点击「X篇笔记」资质入口")
+        self._dismiss_popup_if_exists()
+        self.get_sleep_time()
+        company = self._safe_get_attr('//*[contains(@text, "公司")]')
+        if company:
+            print(f"  公司名={company}")
+
+        self.d.press("back")
+        time.sleep(self.get_sleep_time())
+        self._dismiss_popup_if_exists()
+        self.d.press("back")
+        time.sleep(self.get_sleep_time())
+        self._dismiss_popup_if_exists()
+        return company
+
+    # ============================================================
+    # 6. 提取商品链接
+    # ============================================================
+
+    def get_product_link(self):
+        print("\n[6/7] 提取商品链接...")
+
+        try:
+            self.d.set_clipboard("")
+        except Exception:
+            pass
+
+        share_btn = self.d.xpath('//*[@content-desc="分享商品"]')
+        if not share_btn.exists:
+            print("  [失败] 未找到「分享商品」")
+            return ""
+
+        share_btn.click()
+        time.sleep(1)
+        print("  点击「分享商品」")
+
+        copy_btn = self.d.xpath('//*[@text="复制链接"]')
+        if copy_btn.exists:
+            copy_btn.click()
+            time.sleep(self.SLEEP_CLIPBOARD)
+
+        link = (self.d.clipboard or "").strip()
+        if link:
+            match = re.search(r'https?://xhslink\.com/\S+', link)
+            if match:
+                link = match.group(0)
+            print(f"  商品链接={link}")
+        else:
+            print("  [失败] 剪贴板为空")
+        return link
+
+    # ============================================================
+    # 7. 提取批准文号
+    # ============================================================
+
+    def get_approval_number(self):
+        print("\n[7/7] 提取批准文号...")
+
+        # 步骤1: 小幅度滑动到"批准文号"标签(起点压低避开"退货包运费")
+        w, h = self.d.window_size()
+        for _ in range(6):
+            self.d.swipe(w // 2, int(h * 0.88), w // 2, int(h * 0.50), duration=0.3)
+            time.sleep(self.get_sleep_time())
+            if self.d.xpath('//*[@text="批准文号"]').exists:
+                break
+
+        # 步骤2: 国药准字有就拿,没有就空
+        approval = self._safe_get_attr('//*[starts-with(@text, "国药准字")]')
+        if approval:
+            print(f"  批准文号={approval}")
+        else:
+            print("  [无] 未找到国药准字")
+        return approval
+
+    # ============================================================
+    # 数据聚合
+    # ============================================================
+
+    def integrate_data(self):
+        print(self.SEP)
+        print(f"  小红书商品详情页采集")
+        print(f"  时间: {self.get_current_date()}")
+        print(f"  设备: {self.device_id}")
+        print(f"  目标: {self.product_name}  |  品牌: {self.brand or '-'}  |  规格: {self.spec_list or '-'}")
+        print(self.SEP)
+
+        title = self.get_title()
+        price = self.get_price()
+        spec = self.get_spec()
+        shop_name = self.get_shop_name()
+        company = self.get_company_name()
+        link = self.get_product_link()
+        approval = self.get_approval_number()
+
+        print(f"\n{self.SEP}")
+        print("  采集结果汇总")
+        print(self.SEP)
+        print(f"  标题:       {title}")
+        print(f"  价格:       {price} 元" if price else "  价格:       (未取到)")
+        print(f"  规格:       {spec}")
+        print(f"  店铺名:     {shop_name}")
+        print(f"  公司名:     {company}")
+        print(f"  批准文号:   {approval}")
+        print(f"  商品链接:   {link}")
+        print(self.SEP)
+
+        data = {
+            "product_name": title,
+            "min_price": price,
+            "spec": spec,
+            "shop_name": shop_name,
+            "company_name": company,
+            "approval_number": approval,
+            "product_link": link,
+            "scrape_date": self.get_current_date(),
+        }
+        print(f"\n  完整数据:\n  {data}")
+        return data
+
+    # ============================================================
+    # 进入搜索页
+    # ============================================================
+
+    def enter_search_page(self):
+        """首页 → 市集 → 搜索框 → 粘贴关键字 → 搜索 → 商品列表页"""
+        # 1. 点市集
+        el = self.d.xpath('//*[@text="市集"]')
+        if not el.exists:
+            print("[错误] 未找到「市集」")
+            return False
+        el.click()
+        time.sleep(random.uniform(1.5, 2))
+        self._dismiss_popup_if_exists()
+
+        # 2. 点搜索入口(弹窗遮挡时自动重试)
+        def _find_search_entry():
+            tvs = self.d.xpath('//android.widget.TextView').all()
+            for tv in tvs:
+                try:
+                    b = tv.info.get('bounds', {})
+                    if 150 <= b.get('top', 0) <= 180 and 200 <= b.get('bottom', 0) <= 250:
+                        tv.click()
+                        return True
+                except Exception:
+                    continue
+            return False
+
+        if not self._find_with_popup_retry(_find_search_entry, step_name="搜索入口"):
+            print("[错误] 未找到搜索入口")
+            return False
+        time.sleep(random.uniform(1, 1.5))
+
+        # 3. 输入关键字
+        spec_str = " ".join(self.spec_list) if self.spec_list else ""
+        search_key = f"{self.brand} {self.product_name} {spec_str}".strip()
+        print(f"  搜索词: {search_key}")
+
+        def _find_edit_text():
+            edit = self.d.xpath('//android.widget.EditText')
+            if edit.exists:
+                edit.click()
+                return True
+            return False
+
+        if not self._find_with_popup_retry(_find_edit_text, step_name="搜索输入框"):
+            print("[错误] 未找到搜索输入框")
+            return False
+        time.sleep(0.5)
+        self.d.send_keys(search_key, clear=True)
+        time.sleep(random.uniform(0.5, 1))
+
+        # 4. 点搜索按钮
+        def _find_search_btn():
+            search_btn = self.d.xpath('//*[@text="搜索"]')
+            if search_btn.exists:
+                search_btn.click()
+                return True
+            return False
+
+        if not self._find_with_popup_retry(_find_search_btn, step_name="搜索按钮"):
+            print("[错误] 未找到搜索按钮")
+            return False
+        time.sleep(random.uniform(1.5, 2))
+        self._dismiss_popup_if_exists()
+
+        print("  已进入商品列表页")
+        return True
+
+    # ============================================================
+    # 列表遍历主循环
+    # ============================================================
+
+    def run(self, max_pages=50):
+        """主采集循环:遍历列表页 → 逐个商品 → 详情采集 → 退回列表"""
+        if not self.is_on_list_page():
+            print("[错误] 当前不在商品列表页,请手动进入列表页后重试")
+            return
+
+        collected = 0
+        unrelated = 0
+
+        for page in range(max_pages):
+            print(f"\n{'=' * 60}")
+            print(f"  第 {page + 1} 页")
+            print(f"{'=' * 60}")
+
+            cards = self.get_product_cards()
+            print(f"  当前页 {len(cards)} 个可见商品")
+
+            for i, card in enumerate(cards):
+                # 连续 10 个不达标则暂停
+                if unrelated >= 10:
+                    print(f"\n  [暂停] 连续 {unrelated} 个商品不达标,停止采集")
+                    return
+
+                print(f"\n  [{i + 1}]")
+
+                # 1. 点击卡片进入详情页
+                try:
+                    card.click()
+                except Exception as e:
+                    print(f"    [失败] 点击失败: {e}")
+                    continue
+                time.sleep(self.SLEEP_CLICK)
+                self._dismiss_popup_if_exists()
+
+                # 2. 确认已离开列表页
+                if self.is_on_list_page():
+                    print(f"    [跳过] 点击后仍在列表页")
+                    continue
+
+                # 3. 读详情页标题,判断是否匹配
+                title = self.get_title()
+                if not title:
+                    print(f"    [跳过] 未读到标题")
+                    unrelated += 1
+                    self.back_to_list_page()
+                    continue
+
+                if not self.is_title_useful(title):
+                    print(f"    [跳过] 标题不匹配: {title[:40]}...")
+                    unrelated += 1
+                    self.back_to_list_page()
+                    continue
+
+                # 4. 采集详情(价格、规格、店铺等)
+                unrelated = 0
+                try:
+                    self.integrate_data()
+                    collected += 1
+                    # 每采集 5 个重启一次 atx 服务,防止越来越卡
+                    if collected % 5 == 0:
+                        print("    [维护] 重启自动化服务...")
+                        self._restart_uiautomator_services()
+                except Exception as e:
+                    print(f"    [异常] 采集详情失败: {e}")
+
+                # 5. 退回列表
+                if not self.back_to_list_page():
+                    print("    [警告] 未能回到列表页,尝试继续...")
+                    time.sleep(self.get_sleep_time())
+
+                time.sleep(self.get_sleep_time())
+
+            # 翻到下一页
+            print(f"\n  翻到第 {page + 2} 页...")
+            self.d.swipe_ext("up", scale=0.5)
+            time.sleep(self.get_sleep_time())
+
+        print(f"\n  遍历完成,共 {max_pages} 页")
+
+
+# ============================================================
+# 测试入口
+# ============================================================
+
+def main():
+    print("=" * 60)
+    print("  小红书商品详情页采集器")
+    print("=" * 60)
+
+    xhs = XHS(
+        device_id=DEVICE_ID,
+        product_name=PRODUCT_NAME,
+        brand=BRAND,
+        spec_list=SPEC_LIST,
+    )
+
+    if not xhs.connect_device():
+        print("设备连接失败,退出")
+        return
+
+    # 确保小红书在前台
+    try:
+        current_pkg = xhs.d.app_current().get("package", "")
+        if current_pkg != XHS_PACKAGE:
+            print(f"当前前台非小红书(package={current_pkg}),启动...")
+            xhs.start_app()
+        else:
+            print("小红书已在前台")
+    except Exception as e:
+        print(f"获取前台失败: {e},启动小红书...")
+        xhs.start_app()
+
+    # 自动导航:市集 → 搜索 → 商品列表
+    if not xhs.enter_search_page():
+        print("进入搜索页失败,退出")
+        return
+
+    xhs.run()
+    print("\n采集完成!")
+
+
+if __name__ == "__main__":
+    main()

+ 699 - 0
xhs/spiders/xhs/scheduler.py

@@ -0,0 +1,699 @@
+# coding=utf-8
+"""
+公共调度模块 —— 供 PDD / XHS 等平台复用
+===========================================
+包含:
+  - 数据库连接 get_mysql()
+  - 任务拉取 fetch_pending_tasks(platform) / fetch_idle_devices(platform)
+  - 手动任务支持
+  - 多线程派发 dispatch_pending_tasks(platform, scraper_class, ...)
+  - 心跳上报 report_heartbeat / 状态上报 report_api
+  - 飞书通知
+  - ADB 设备状态
+
+使用方式:
+  from common.scheduler import (
+      get_mysql, dispatch_pending_tasks,
+      PLATFORM_PDD, PLATFORM_XHS,
+      configure_scheduler, run_scheduler,
+  )
+
+  # 在 main.py 中定义自己的 ScraperClass,然后:
+  configure_scheduler(
+      platform_id=12,
+      scraper_class=XHS,
+      table_name="retrieve_scrape_data_ybm_test",
+      ...
+  )
+  run_scheduler()
+"""
+
+import sys
+import os
+import time
+import json
+import random
+import subprocess
+import threading
+import logging
+import datetime
+import builtins
+import pymysql
+
+# ---- 尝试导入可选依赖 ----
+try:
+    from feishu_webhook import send_text as feishu_send_text
+    from feishu_webhook import send_error_card as feishu_send_error_card
+    FEISHU_WEBHOOK_ENABLED = True
+except ImportError:
+    feishu_send_text = None
+    feishu_send_error_card = None
+    FEISHU_WEBHOOK_ENABLED = False
+
+try:
+    from logger import setup_logger as _setup_logger
+except ImportError:
+    def _setup_logger(name): pass
+
+try:
+    from pdd_config import Config
+except ImportError:
+    Config = None
+
+# ============================================================
+# 日志(统一接口,默认只打控制台)
+# ============================================================
+_ORIGINAL_PRINT = builtins.print
+
+
+def print(*args, sep=" ", end="\n", file=None, flush=False):
+    message = sep.join(str(arg) for arg in args)
+    if end and end != "\n":
+        message = f"{message}{end}"
+    logged = False
+    try:
+        root_logger = logging.getLogger()
+        if root_logger.handlers:
+            root_logger.info(message.rstrip("\n"))
+            logged = True
+    except Exception:
+        logged = False
+    if not logged:
+        _ORIGINAL_PRINT(*args, sep=sep, end=end, file=file if file else sys.stdout, flush=flush)
+    if file not in (None, sys.stdout, sys.stderr):
+        _ORIGINAL_PRINT(*args, sep=sep, end=end, file=file, flush=flush)
+
+
+def init_logger(name):
+    """初始化统一日志"""
+    _setup_logger(name)
+
+
+# ============================================================
+# 常量
+# ============================================================
+PLATFORM_PDD = 3
+PLATFORM_XHS = 4   # 默认,可在 configure_scheduler 中覆盖
+
+TASK_STATUS_PENDING = 1
+DEVICE_STATUS_IDLE = 0
+DEFAULT_MAX_COUNTS_LIMIT = 300
+
+HEARTBEAT_INTERVAL_SECONDS = 60
+HEARTBEAT_API_URL = "https://scheduleapi.findit.ltd/api/collect_equipment_execute/heartbeat"
+REPORT_API_URL = "http://scheduleapi.findit.ltd/api/collect_equipment_execute/result_report"
+
+SCHEDULER_INTERVAL_SECONDS = 600
+MANUAL_SCHEDULER_INTERVAL_SECONDS = 300
+
+# ============================================================
+# 飞书
+# ============================================================
+def safe_feishu_send_text(text):
+    if not FEISHU_WEBHOOK_ENABLED or feishu_send_text is None:
+        return
+    try:
+        feishu_send_text(text)
+    except Exception as e:
+        logging.info(f"飞书文本上报失败: {e}")
+
+
+def safe_feishu_send_error(task_name, err_msg, mention_all=False):
+    if not FEISHU_WEBHOOK_ENABLED or feishu_send_error_card is None:
+        return
+    try:
+        feishu_send_error_card(task_name, err_msg, mention_all=mention_all)
+    except Exception as e:
+        logging.info(f"飞书异常卡片上报失败: {e}")
+
+
+# ============================================================
+# 数据库
+# ============================================================
+def get_mysql():
+    return pymysql.connect(
+        host='120.25.48.236',
+        port=3306,
+        user='drug_retrieve',
+        password='ksCt3xm6chzdkafj',
+        db='drug_retrieve',
+        charset='utf8mb4'
+    )
+
+
+# ============================================================
+# 工具函数
+# ============================================================
+def parse_optional_int(value, default=None):
+    if value in (None, ""):
+        return default
+    try:
+        return int(value)
+    except (TypeError, ValueError):
+        return default
+
+
+# ============================================================
+# ADB
+# ============================================================
+def get_adb_device_status_map():
+    try:
+        result = subprocess.run(
+            ["adb", "devices"],
+            capture_output=True,
+            text=True,
+            timeout=10,
+        )
+    except Exception as e:
+        logging.exception(f"读取 ADB 设备状态失败: {e}")
+        return {}
+    status_map = {}
+    for line in result.stdout.splitlines():
+        line = line.strip()
+        if not line or line.startswith("List of devices"):
+            continue
+        parts = line.split()
+        if len(parts) >= 2:
+            status_map[parts[0]] = parts[1]
+    return status_map
+
+
+def is_adb_device_online(device_id, adb_status_map=None):
+    device_id = str(device_id or "").strip()
+    if not device_id:
+        return False
+    if adb_status_map is None:
+        adb_status_map = get_adb_device_status_map()
+    return adb_status_map.get(device_id) == "device"
+
+
+# ============================================================
+# API 上报
+# ============================================================
+def report_api(task_id, page=None, start=None, end_page=None, end_time=None,
+               finish_status=None, collect_round=None):
+    import requests
+    params = {
+        "collect_task_allocate_id": task_id,
+        "start_page": page if page is not None else '',
+        "end_page": end_page if end_page is not None else '',
+        "collect_round": collect_round if collect_round is not None else '',
+        "status": start,
+        "finish_status": finish_status if finish_status is not None else 0,
+        "start_time": int(time.time()),
+        "end_time": end_time if end_time is not None else '',
+    }
+    try:
+        res = requests.get(REPORT_API_URL, params=params, timeout=20)
+        print(res.text)
+    except Exception as e:
+        logging.exception(f"上报接口异常 task_id={task_id}: {e}")
+
+
+def report_heartbeat(task_id, page=None):
+    import requests
+    params = {
+        "collect_task_allocate_id": task_id,
+        "heartbeat_time": int(time.time()),
+        "current_page": page if page is not None else 0,
+    }
+    try:
+        requests.get(HEARTBEAT_API_URL, params=params, timeout=10)
+    except Exception as e:
+        logging.warning(f"心跳上报失败: {e}")
+
+
+# ============================================================
+# 任务 / 设备查询(platform 参数化)
+# ============================================================
+def fetch_pending_tasks(platform):
+    conn = None
+    try:
+        conn = get_mysql()
+        with conn.cursor() as cursor:
+            sql = """
+                SELECT *
+                FROM retrieve_collect_task_allocate
+                WHERE platform = %s AND status = %s
+                ORDER BY id ASC
+            """
+            cursor.execute(sql, (platform, TASK_STATUS_PENDING))
+            return cursor.fetchall()
+    except Exception as e:
+        logging.exception(f"读取待执行任务失败 (platform={platform}): {e}")
+        return []
+    finally:
+        if conn:
+            conn.close()
+
+
+def fetch_idle_devices(platform):
+    conn = None
+    try:
+        conn = get_mysql()
+        with conn.cursor() as cursor:
+            sql = """
+                SELECT *
+                FROM retrieve_collect_equipment_account
+                WHERE platform = %s AND status = %s
+                ORDER BY id ASC
+            """
+            cursor.execute(sql, (platform, DEVICE_STATUS_IDLE))
+            return cursor.fetchall()
+    except Exception as e:
+        logging.exception(f"读取空闲设备失败 (platform={platform}): {e}")
+        return []
+    finally:
+        if conn:
+            conn.close()
+
+
+def fetch_idle_device_by_equipment_id(equipment_id):
+    conn = None
+    try:
+        conn = get_mysql()
+        with conn.cursor() as cursor:
+            sql = """
+                SELECT *
+                FROM retrieve_collect_equipment_account
+                WHERE collect_equipment_id = %s AND status = %s
+                LIMIT 1
+            """
+            cursor.execute(sql, (equipment_id, DEVICE_STATUS_IDLE))
+            return cursor.fetchone()
+    except Exception as e:
+        logging.exception(f"读取空闲设备失败 equipment_id={equipment_id}: {e}")
+        return None
+    finally:
+        if conn:
+            conn.close()
+
+
+def build_task_payload(task_row, device_row):
+    """数据库任务行 → 统一 payload 字典"""
+    sampling_cycle = str(task_row[12]).strip() if len(task_row) > 12 else ''
+    sampling_start_time = parse_optional_int(task_row[13] if len(task_row) > 13 else None, 0)
+    sampling_end_time = parse_optional_int(task_row[14] if len(task_row) > 14 else None, 0)
+    start_page = parse_optional_int(task_row[15] if len(task_row) > 15 else None, 0)
+    end_page = parse_optional_int(task_row[16] if len(task_row) > 16 else None, 300)
+    max_counts_limit = parse_optional_int(
+        task_row[17] if len(task_row) > 17 else None,
+        DEFAULT_MAX_COUNTS_LIMIT
+    )
+    collect_config_info = json.dumps({
+        "sampling_cycle": sampling_cycle,
+        "sampling_start_time": sampling_start_time,
+        "sampling_end_time": sampling_end_time,
+    }, ensure_ascii=False)
+
+    return {
+        "task_id": task_row[0],
+        "task_name": task_row[1],
+        "collect_equipment_account_id": task_row[3],
+        "collect_region_id": task_row[4],
+        "equipment_id": task_row[2],
+        "enterprise_id": task_row[5],
+        "platform": task_row[6],
+        "title_key": task_row[7],
+        "spec_list": task_row[8],
+        "brand": task_row[9],
+        "search_key": f"{task_row[9] or ''}{task_row[7] or ''} {task_row[8] or ''}".strip(),
+        "save_search_key": f"{task_row[9] or ''}{task_row[7] or ''} {task_row[8] or ''}".strip(),
+        "start_page": start_page,
+        "end_page": end_page,
+        "max_counts_limit": max_counts_limit,
+        "collect_config_info": collect_config_info,
+        "collect_round": task_row[24] if len(task_row) > 24 else 1,
+        "sort": "默认",
+        "device_id": device_row[11],
+        "task_row": task_row,
+    }
+
+
+# ============================================================
+# 内存状态
+# ============================================================
+dispatch_lock = threading.Lock()
+running_task_ids = set()
+running_device_ids = set()
+worker_threads = {}
+scheduler_stop_event = threading.Event()
+scheduler_timer = None
+
+
+# ============================================================
+# Worker 生命周期管理
+# ============================================================
+def cleanup_finished_workers():
+    with dispatch_lock:
+        dead_device_ids = [
+            d for d, t in worker_threads.items() if not t.is_alive()
+        ]
+        for d in dead_device_ids:
+            worker_threads.pop(d, None)
+            running_device_ids.discard(d)
+        orphan = [
+            d for d in running_device_ids if d not in worker_threads
+        ]
+        for d in orphan:
+            running_device_ids.discard(d)
+        if not worker_threads and running_task_ids:
+            logging.info("cleanup: 无存活 worker,清空残留 running_task_ids")
+            running_task_ids.clear()
+
+
+def has_active_workers():
+    cleanup_finished_workers()
+    with dispatch_lock:
+        return any(t.is_alive() for t in worker_threads.values())
+
+
+# ============================================================
+# 任务 Payload 组装
+# ============================================================
+def fetch_runnable_task_payloads(platform, use_manual=False, manual_tasks=None,
+                                  manual_dispatched_task_ids_ref=None):
+    """
+    统一入口:手动模式 or 数据库模式
+    返回 [(task_payload, scraper_kwargs), ...]
+    """
+    if use_manual:
+        return _fetch_manual_payloads(platform, manual_tasks or [],
+                                       manual_dispatched_task_ids_ref or set())
+    return _fetch_db_payloads(platform)
+
+
+def _fetch_db_payloads(platform):
+    tasks = fetch_pending_tasks(platform)
+    if not tasks:
+        return []
+    payloads = []
+    reserved_equipment_ids = set()
+    for task_row in tasks:
+        task_id = task_row[0]
+        equipment_id = task_row[2]
+        with dispatch_lock:
+            if task_id in running_task_ids:
+                continue
+            if equipment_id in reserved_equipment_ids:
+                continue
+        device_row = fetch_idle_device_by_equipment_id(equipment_id)
+        if not device_row:
+            logging.info(f"任务 {task_id} 对应设备 {equipment_id} 不空闲,跳过")
+            continue
+        device_id = device_row[11]
+        with dispatch_lock:
+            if device_id in running_device_ids:
+                continue
+            running_task_ids.add(task_id)
+            running_device_ids.add(device_id)
+            reserved_equipment_ids.add(equipment_id)
+        payloads.append(build_task_payload(task_row, device_row))
+    return payloads
+
+
+def _fetch_manual_payloads(platform, manual_tasks, dispatched_ids):
+    """手动模式:直接从 MANUAL_TASKS 构建 payload,不查库"""
+    payloads = []
+    for i, task in enumerate(manual_tasks):
+        task_id = parse_optional_int(task.get("task_id"), 900000 + i + 1)
+        if task_id in dispatched_ids:
+            continue
+        search_key = str(task.get("search_key", "")).strip()
+        if not search_key:
+            logging.warning(f"跳过手动任务,缺少 search_key: {task}")
+            continue
+        device_id = str(task.get("device_id", "")).strip()
+        if not device_id:
+            logging.warning(f"跳过手动任务 {task_id},缺少 device_id")
+            continue
+
+        title_key = task.get("title_key")
+        save_search_key = task.get("save_search_key")
+
+        with dispatch_lock:
+            if device_id in running_device_ids:
+                logging.info(f"手动任务 {task_id} 设备 {device_id} 已被占用,本轮跳过")
+                continue
+            running_task_ids.add(task_id)
+            running_device_ids.add(device_id)
+
+        payloads.append({
+            "task_id": task_id,
+            "equipment_id": parse_optional_int(task.get("equipment_id"), 0),
+            "enterprise_id": parse_optional_int(task.get("enterprise_id"), 0),
+            "platform": platform,
+            "title_key": title_key if title_key not in (None, "") else search_key,
+            "spec_list": task.get("spec_list", ""),
+            "brand": task.get("brand", ""),
+            "search_key": search_key,
+            "save_search_key": save_search_key if save_search_key not in (None, "") else search_key,
+            "start_page": parse_optional_int(task.get("start_page"), 0),
+            "end_page": parse_optional_int(task.get("end_page"), None),
+            "max_counts_limit": parse_optional_int(task.get("max_counts_limit"),
+                                                    DEFAULT_MAX_COUNTS_LIMIT),
+            "collect_config_info": task.get("collect_config_info", ""),
+            "collect_equipment_account_id": task.get("collect_equipment_account_id", ""),
+            "collect_region_id": task.get("collect_region_id", ""),
+            "collect_round": task.get("collect_round", ""),
+            "sort": task.get("sort", "默认"),
+            "device_id": device_id,
+            "task_row": None,
+        })
+        dispatched_ids.add(task_id)
+
+    if payloads:
+        logging.info(f"手动任务模式,本轮分发 {len(payloads)} 条")
+    return payloads
+
+
+# ============================================================
+# 线程入口(通用)
+# ============================================================
+def run_task_worker(task_payload, ScraperClass, platform_name="未知平台"):
+    """
+    每个设备的独立线程
+    ScraperClass: 采集类 (PDD / XHS / ...),需接受 task_payload 中各字段作为构造参数
+    """
+    task_id = task_payload["task_id"]
+    device_id = task_payload["device_id"]
+    search_key = str(task_payload.get("search_key", "")).strip()
+    scraper = None
+    should_send_finish_notice = False
+
+    try:
+        logging.info(f"[任务 {task_id}] 开始执行,设备: {device_id},平台: {platform_name}")
+        print(task_payload)
+
+        scraper = ScraperClass(
+            search_key=task_payload["search_key"],
+            device_id=device_id,
+            title_key=task_payload.get("title_key"),
+            spec_list=task_payload.get("spec_list"),
+            brand=task_payload.get("brand", ""),
+            save_search_key=task_payload.get("save_search_key"),
+            start_page=task_payload.get("start_page"),
+            end_page=task_payload.get("end_page"),
+            max_counts_limit=task_payload.get("max_counts_limit"),
+            collect_config_info=task_payload.get("collect_config_info", ""),
+            sort=task_payload.get("sort"),
+            platform=task_payload.get("platform"),
+            task_id=task_payload.get("task_id"),
+            enterprise_id=task_payload.get("enterprise_id"),
+            collect_round=task_payload.get("collect_round"),
+            collect_equipment_account_id=task_payload.get("collect_equipment_account_id", 1),
+            collect_region_id=task_payload.get("collect_region_id", 1),
+        )
+
+        completed_normally = scraper.main()
+        status_text = "执行完成" if completed_normally else "已结束"
+        logging.info(f"[任务 {task_id}] {status_text},设备: {device_id}")
+        should_send_finish_notice = True
+
+    except Exception as e:
+        end_page = task_payload.get("start_page")
+        err_msg = str(e)
+        if scraper is not None:
+            end_page = getattr(scraper, "page", end_page)
+            scraper.finish_task_abnormally(end_page, f"任务执行异常: {err_msg}")
+        else:
+            report_api(task_id, end_page=end_page, start=4, end_time=int(time.time()),
+                       finish_status=0, collect_round=task_payload.get("collect_round"))
+        logging.exception(f"[任务 {task_id}] 执行异常,设备: {device_id},错误: {err_msg}")
+        safe_feishu_send_error(
+            task_name=f"{platform_name}任务(task_id={task_id}, device={device_id}, key={search_key})",
+            err_msg=err_msg,
+            mention_all=False,
+        )
+        should_send_finish_notice = True
+
+    finally:
+        with dispatch_lock:
+            running_task_ids.discard(task_id)
+            running_device_ids.discard(device_id)
+            worker_threads.pop(device_id, None)
+
+        if should_send_finish_notice:
+            scraped_count = getattr(scraper, "max_counts", 0) if scraper is not None else 0
+            notify_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+            spec_value = task_payload.get("spec_list", "")
+            if isinstance(spec_value, (list, tuple, set)):
+                spec_text = "、".join(str(i).strip() for i in spec_value if str(i).strip())
+            else:
+                spec_text = str(spec_value or "").strip()
+            if not spec_text:
+                spec_text = "-"
+            safe_feishu_send_text(
+                f"{notify_time} 通知:\n平台: {platform_name}, 药品: {search_key}, "
+                f"品规: {spec_text}, 爬取数据: {scraped_count}条"
+            )
+
+
+# ============================================================
+# 派单
+# ============================================================
+def dispatch_pending_tasks(platform, ScraperClass, platform_name="未知平台",
+                            use_manual=False, manual_tasks=None,
+                            manual_dispatched_task_ids_ref=None):
+    """一轮派单"""
+    cleanup_finished_workers()
+    payloads = fetch_runnable_task_payloads(
+        platform,
+        use_manual=use_manual,
+        manual_tasks=manual_tasks or [],
+        manual_dispatched_task_ids_ref=manual_dispatched_task_ids_ref or set(),
+    )
+    if not payloads:
+        return
+
+    for p in payloads:
+        device_id = p["device_id"]
+        try:
+            thread = threading.Thread(
+                target=run_task_worker,
+                args=(p, ScraperClass, platform_name),
+                daemon=True,
+                name=f"scraper-{device_id}",
+            )
+            with dispatch_lock:
+                worker_threads[device_id] = thread
+            thread.start()
+            logging.info(f"[任务 {p['task_id']}] 已分发到设备 {device_id}")
+        except Exception:
+            with dispatch_lock:
+                running_task_ids.discard(p["task_id"])
+                running_device_ids.discard(device_id)
+                worker_threads.pop(device_id, None)
+            raise
+
+
+# ============================================================
+# 定时调度
+# ============================================================
+def schedule_dispatch(delay_seconds, job_func):
+    global scheduler_timer
+    if scheduler_stop_event.is_set():
+        return
+    scheduler_timer = threading.Timer(delay_seconds, job_func)
+    scheduler_timer.daemon = False
+    scheduler_timer.name = "scraper-scheduler"
+    scheduler_timer.start()
+
+
+def _make_scheduled_job(platform, ScraperClass, platform_name,
+                         use_manual, manual_tasks, dispatched_ids_ref,
+                         interval_seconds):
+    """工厂:创建定时调度 job 函数"""
+    def job():
+        try:
+            dispatch_pending_tasks(
+                platform, ScraperClass, platform_name,
+                use_manual=use_manual,
+                manual_tasks=manual_tasks,
+                manual_dispatched_task_ids_ref=dispatched_ids_ref,
+            )
+        except Exception as e:
+            logging.exception(f"{platform_name} 定时调度异常: {e}")
+        finally:
+            schedule_dispatch(interval_seconds, job)
+    return job
+
+
+def _make_manual_job(platform, ScraperClass, platform_name,
+                      manual_tasks, dispatched_ids_ref, interval_seconds):
+    """工厂:创建手动模式定时 job,任务跑完自动停"""
+    def job():
+        try:
+            dispatch_pending_tasks(
+                platform, ScraperClass, platform_name,
+                use_manual=True,
+                manual_tasks=manual_tasks,
+                manual_dispatched_task_ids_ref=dispatched_ids_ref,
+            )
+        except Exception as e:
+            logging.exception(f"{platform_name} 手动任务调度异常: {e}")
+        finally:
+            all_done = _all_manual_dispatched(manual_tasks, dispatched_ids_ref)
+            if all_done and not has_active_workers():
+                logging.info(f"{platform_name} 手动模式:全部任务已完成,停止轮询")
+                scheduler_stop_event.set()
+                return
+            schedule_dispatch(interval_seconds, job)
+    return job
+
+
+def _all_manual_dispatched(manual_tasks, dispatched_ids):
+    expected = {
+        parse_optional_int(t.get("task_id"), 900000 + i + 1)
+        for i, t in enumerate(manual_tasks)
+    }
+    return expected.issubset(dispatched_ids)
+
+
+# ============================================================
+# 一键启动
+# ============================================================
+def run_scheduler(platform, ScraperClass,
+                   platform_name="未知平台",
+                   use_manual=False,
+                   manual_tasks=None,
+                   log_name="scraper"):
+    """
+    启动调度器(阻塞)
+    :param platform:      平台 ID (int)
+    :param ScraperClass:  采集类
+    :param platform_name: 平台中文名,用于日志/飞书
+    :param use_manual:    是否手动任务模式
+    :param manual_tasks:  手动任务列表
+    :param log_name:      日志文件名前缀
+    """
+    init_logger(log_name)
+
+    interval = MANUAL_SCHEDULER_INTERVAL_SECONDS if use_manual else SCHEDULER_INTERVAL_SECONDS
+    logging.info(f"{platform_name} 调度器启动,平台ID={platform},间隔={interval}s")
+
+    dispatched_ids = set()
+
+    # 先立即派发一轮
+    dispatch_pending_tasks(
+        platform, ScraperClass, platform_name,
+        use_manual=use_manual,
+        manual_tasks=manual_tasks or [],
+        manual_dispatched_task_ids_ref=dispatched_ids,
+    )
+
+    if use_manual:
+        if _all_manual_dispatched(manual_tasks or [], dispatched_ids) and not has_active_workers():
+            logging.info(f"{platform_name} 手动模式:全部任务已完成,进程退出")
+            return
+        job = _make_manual_job(platform, ScraperClass, platform_name,
+                                manual_tasks or [], dispatched_ids,
+                                MANUAL_SCHEDULER_INTERVAL_SECONDS)
+        schedule_dispatch(MANUAL_SCHEDULER_INTERVAL_SECONDS, job)
+    else:
+        job = _make_scheduled_job(platform, ScraperClass, platform_name,
+                                   False, [], set(),
+                                   SCHEDULER_INTERVAL_SECONDS)
+        schedule_dispatch(SCHEDULER_INTERVAL_SECONDS, job)
+
+    scheduler_stop_event.wait()

+ 12 - 0
xhs/spiders/xhs/xhs_config.py

@@ -0,0 +1,12 @@
+# coding=utf-8
+"""
+小红书采集器 — OSS 快照配置
+从拼多多 pdd_config 里拿过来的
+"""
+
+# OSS 配置
+access_key_id = 'LTAI5t5pWgfa1BMztEuWBjdK'
+access_key_secret = 'wU7FLzEr1NqLg2rJrmAu7Ibn69np0u'
+endpoint = "oss-cn-shenzhen.aliyuncs.com"
+bucket_name = "zhijiayun-jiansuo"
+oss_prefix = "scrape_data/"

+ 209 - 0
xhs/start_run_xhs.py

@@ -0,0 +1,209 @@
+"""
+小红书 (XHS) 平台采集入口
+调度、数据库、飞书通知全部走 commons。
+"""
+import os
+import re
+import sys
+import random
+import time
+import subprocess
+from datetime import datetime
+
+# xhs main.py 内部 import xhs_config 需要 spiders/xhs 在 path 里
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "spiders", "xhs"))
+
+from commons.scheduler import CrawlerScheduler
+from commons.Logger import get_spider_logger
+from commons.feishu_webhook import send_text, send_error_card
+from commons.conn_mysql import MySQLPoolOn2
+from spiders.xhs.main import XHS, XHS_PLATFORM_ID
+
+logger = get_spider_logger("xhs_runner")
+
+PLATFORM_NAME = "小红书"
+IDLE_SECONDS_MIN = 30
+IDLE_SECONDS_MAX = 90
+
+
+def get_local_adb_devices():
+    """获取当前电脑连接的所有 ADB 设备序列号"""
+    try:
+        output = subprocess.check_output(['adb', 'devices'], stderr=subprocess.STDOUT).decode('utf-8', errors='ignore')
+        serials = []
+        for line in output.splitlines():
+            parts = line.strip().split()
+            if len(parts) == 2 and parts[1] == 'device':
+                serials.append(parts[0])
+        return serials
+    except Exception:
+        return []
+
+def get_device_from_db():
+    """从 DB 拿一个小红书空闲设备,返回 (username, device_id)
+    如果发现有设备卡在 status=1 但物理连接在当前电脑上,自动将其重置为 0
+    """
+    db = MySQLPoolOn2()
+    
+    # 1. 优先尝试直接获取空闲设备
+    rows = db.select_data(
+        "SELECT * FROM retrieve_collect_equipment_account WHERE platform = %s AND status = 0 ORDER BY id ASC",
+        (XHS_PLATFORM_ID,),
+    )
+    if rows:
+        row = rows[0]
+        values = list(row.values()) if isinstance(row, dict) else row
+        username = values[4] if len(values) > 4 else None
+        device_id = values[11] if len(values) > 11 else username
+        return username, device_id
+
+    # 2. 如果没有空闲设备,查找是否由于断电/断网导致设备状态卡在 1(死锁)
+    stuck_rows = db.select_data(
+        "SELECT * FROM retrieve_collect_equipment_account WHERE platform = %s AND status = 1 ORDER BY id ASC",
+        (XHS_PLATFORM_ID,),
+    )
+    if stuck_rows:
+        local_serials = get_local_adb_devices()
+        for row in stuck_rows:
+            values = list(row.values()) if isinstance(row, dict) else row
+            username = values[4] if len(values) > 4 else None
+            device_id = values[11] if len(values) > 11 else username
+            
+            # 如果卡死的设备确实插在这台电脑上,说明是前一次运行崩溃留下的僵尸状态
+            if device_id and device_id in local_serials:
+                logger.warning(f"发现本地设备 {username}({device_id}) 状态异常卡在 1 (运行中)!")
+                logger.warning("正在自动将其强制重置为 0 (空闲) 以恢复运行...")
+                try:
+                    db.update_data(
+                        "UPDATE retrieve_collect_equipment_account SET status = 0 WHERE platform = %s AND account_number = %s",
+                        (XHS_PLATFORM_ID, username)
+                    )
+                    logger.info("重置成功!已恢复该设备状态。")
+                    return username, device_id
+                except Exception as e:
+                    logger.error(f"尝试自动重置设备死锁失败: {e}")
+                    
+    return None, None
+
+
+def notify_result(task, crawl_count, success):
+    """发送飞书通知"""
+    try:
+        drug_name = task.get("search_key", task.get("product_name", ""))
+        notice_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+        text_msg = (
+            f"{notice_time} 通知:\n"
+            f"平台: {PLATFORM_NAME}, 关键词: {drug_name}, 爬取数据: {crawl_count}条"
+        )
+        if success:
+            send_text(text_msg)
+        else:
+            send_error_card(
+                task_name=drug_name,
+                err_msg=f"task_id={task.get('task_id')}, crawl_count={crawl_count}",
+                mention_all=False,
+            )
+    except Exception as e:
+        logger.warning("飞书通知发送失败:%s", e)
+
+
+def update_device_status(username, platform_id, new_status):
+    """更新设备状态:0=空闲 1=运行中"""
+    try:
+        db = MySQLPoolOn2()
+        sql = "UPDATE retrieve_collect_equipment_account SET status = %s WHERE username = %s AND platform = %s"
+        db.update_data(sql, (new_status, username, platform_id))
+        logger.info(f"==== 已将设备 {username} 的状态修改为 {new_status} ====")
+    except Exception as e:
+        logger.error(f"更新设备状态失败: {e}")
+
+
+def main_loop():
+    """循环拉取任务并执行采集"""
+    username, device_id = get_device_from_db()
+    if not username:
+        logger.error("小红书 无可用设备,退出")
+        return
+
+    # 【1】获取到空闲设备后,立刻把数据库里的状态改成 1(运行中)
+    update_device_status(username, XHS_PLATFORM_ID, 1)
+
+    try:
+        scheduler = CrawlerScheduler(username, str(XHS_PLATFORM_ID))
+        idle_seconds = random.randint(IDLE_SECONDS_MIN, IDLE_SECONDS_MAX)
+        logger.info("小红书采集启动,设备=%s adb=%s 每轮间隔 %s 秒", username, device_id, idle_seconds)
+
+        while True:
+            task_id = None
+            try:
+                scheduler.start()
+                task = scheduler.get_task() or {}
+                if not task:
+                    logger.info("%s 暂无任务,等待 %s 秒后重试", PLATFORM_NAME, idle_seconds)
+                    time.sleep(idle_seconds)
+                    continue
+
+                task_id = task.get("id")
+                # API 字段映射(模仿 PDD build_task_payload_from_api)
+                brand = task.get("product_brand", "") or ""
+                title_key = task.get("product_name", "") or ""
+                spec_raw = task.get("product_specs", "") or ""
+                spec_list = [s.strip() for s in re.split(r'[|、,,\n\r]+', spec_raw) if s.strip()]
+                search_key = f"{brand}{title_key} {spec_raw}".strip()
+                save_search_key = task.get("product_keyword") or search_key
+                logger.info("开始执行%s采集任务 task_id=%s keyword=%s", PLATFORM_NAME, task_id, search_key)
+
+                xhs = XHS(
+                    search_key=search_key,
+                    device_id=device_id,
+                    title_key=title_key,
+                    spec_list=spec_list,
+                    brand=brand,
+                    save_search_key=save_search_key,
+                    start_page=0,  # 取消恢复历史进度,取到任务就从第一个商品开始
+                    end_page=task.get("end_page"),
+                    max_counts_limit=task.get("max_counts_limit"),
+                    collect_config_info=task.get("collect_config_info", ""),
+                    sort=task.get("sort"),
+                    platform=XHS_PLATFORM_ID,
+                    task_id=task_id,
+                    enterprise_id=task.get("company_id"),
+                    collect_round=task.get("collect_round"),
+                    collect_equipment_account_id=task.get("collect_equipment_account_id"),
+                    collect_region_id=task.get("collect_region_id"),
+                    username=username,
+                )
+                completed = xhs.main()
+                crawl_count = getattr(xhs, 'max_counts', 0)
+                logger.info("%s采集任务完成 task_id=%s count=%s success=%s",
+                            PLATFORM_NAME, task_id, crawl_count, completed)
+
+                notify_result(task, crawl_count, completed)
+
+            except Exception as e:
+                logger.error("%s 采集任务执行失败: %s", PLATFORM_NAME, e, exc_info=True)
+
+            time.sleep(idle_seconds)
+
+    except KeyboardInterrupt:
+        # 【2】专门捕获手动断开 (如在终端按下 Ctrl+C 等操作)
+        logger.info("检测到程序被手动停止(Ctrl+C),准备释放设备...")
+    except Exception as e:
+        # 捕获其他意料之外的严重崩溃
+        logger.error("主程序发生崩溃异常: %s", e)
+    finally:
+        # 【3】不管是因为报错退出、还是人为强行断开,这里都会尽最大努力执行,改回 0
+        # 增加重试机制,防止断网瞬间改不回去
+        for attempt in range(3):
+            try:
+                update_device_status(username, XHS_PLATFORM_ID, 0)
+                break
+            except Exception as e:
+                logger.error(f"释放设备状态失败 (尝试 {attempt+1}/3): {e}")
+                time.sleep(2)
+        logger.info("设备已安全释放,状态恢复为 0。退出程序。")
+
+
+
+if __name__ == '__main__':
+    main_loop()

二进制
xhs/xhs_exported.apk