3v324v23 commited on
Commit
a241a73
·
1 Parent(s): b643537

feat: 接入巴西真实海关数据源并优化回补逻辑与HTTP客户端

Browse files

- 实现巴西Comex Stat官方开放CSV数据源的拉取、解析与标准化流程,替换原Mock模拟数据
- 优化回补脚本run_backfill.py,支持巴西按年月范围批量执行回补任务
- 修复HTTP客户端SSL证书校验失败的降级重试逻辑,新增代理与SSL参数配置能力
- 添加项目下一步开发规划文档与测试日志示例

apps/worker/__pycache__/run_backfill.cpython-314.pyc ADDED
Binary file (4.92 kB). View file
 
apps/worker/run_backfill.py CHANGED
@@ -28,11 +28,21 @@ async def run_backfill(country_code: str, start_date: str, end_date: str):
28
  end_dt = datetime.strptime(end_date, "%Y-%m-%d")
29
 
30
  async with AsyncSessionLocal() as session:
 
 
 
 
 
 
 
 
 
 
 
 
31
  # 1. 实例化对应的 Connector
32
  if country_code == "US":
33
  connector = MockUSConnector(session=session)
34
- elif country_code == "BR":
35
- connector = BrazilComexStatConnector(session=session)
36
  elif country_code in COUNTRY_CONFIGS:
37
  connector = ExtendedMockConnector(country_code=country_code, session=session)
38
  else:
 
28
  end_dt = datetime.strptime(end_date, "%Y-%m-%d")
29
 
30
  async with AsyncSessionLocal() as session:
31
+ # 巴西真实源支持按月范围回补,直接一次性下发给 connector。
32
+ if country_code == "BR":
33
+ connector = BrazilComexStatConnector(
34
+ session=session,
35
+ start_period=start_dt.strftime("%Y-%m"),
36
+ end_period=end_dt.strftime("%Y-%m"),
37
+ )
38
+ connector.batch_no = f"BACKFILL_{country_code}_{start_dt.strftime('%Y%m')}_{end_dt.strftime('%Y%m')}"
39
+ await connector.run()
40
+ app_logger.info(f"--- Finished BACKFILL Job for {country_code} ---")
41
+ return
42
+
43
  # 1. 实例化对应的 Connector
44
  if country_code == "US":
45
  connector = MockUSConnector(session=session)
 
 
46
  elif country_code in COUNTRY_CONFIGS:
47
  connector = ExtendedMockConnector(country_code=country_code, session=session)
48
  else:
docs/下一步开发规划与数据扩充方案.md ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 下一步开发规划与数据扩充方案
2
+
3
+ ## 1. 现状评估
4
+
5
+ 根据当前项目代码和文档的实际情况,项目的核心架构(抓取 -> 原始留存 -> 标准化 -> 入库)和数据模型已经跑通。
6
+ **关键状态**:目前所有国家(包括巴西、美国及扩展的 7 个国家)都在使用 Mock 模拟数据,尚未接入真实的外部网络请求。
7
+
8
+ ## 2. 哪个国家的数据最好拿?(国家接入优先级)
9
+
10
+ 从全球海关数据的开放度来看,拉美地区和部分官方统计源最容易获取,且合规风险最低。
11
+
12
+ ### 第一梯队(最好拿,官方全量开放,字段全)
13
+ 建议作为真实数据首个突破口。
14
+ * **巴西 (Brazil)**:官方的 Comex Stat 平台提供极高透明度的数据,可以直接下载包含明细的 CSV 文件或调用公开 API。
15
+ * **其他拉美国家 (如智利、哥伦比亚、秘鲁)**:普遍在政府公开网站提供进出口详细数据下载,字段完整度高。
16
+
17
+ ### 第二梯队(数据结构好,但缺少部分敏感信息)
18
+ * **欧盟 (Eurostat) / 英国**:官方 API 非常规范,数据极度稳定,适合做宏观与商品流向分析,但**没有具体的企业名**。
19
+ * **墨西哥 (Mexico)**:官方 SIAVI 平台有数据包,但解析复杂(多为西班牙语编码压缩包)。
20
+
21
+ ### 第三梯队(难拿,官方封闭,需对抗反爬或购买第三方)
22
+ * **美国 (US)**:官方(CBP)只有汇总,真实的提单(B/L)数据需要从港口或第三方商业公司获取。
23
+ * **印度 (India) / 越南 (Vietnam)**:官方经常关闭企业名或加严格验证码,市面上的明细数据基本靠第三方平台(如 Zauba)内部渠道流出,抓取难度大、数据脏。
24
+
25
+ ## 3. 怎么拿到更全的数据?(数据获取策略)
26
+
27
+ “全”分为**覆盖全**和**历史全**,建议采用“官方打底 + 第三方补全”的策略:
28
+
29
+ 1. **历史全量靠“静态下载”,增量靠“API轮询”**:
30
+ 对于巴西等国,不要用 API 去爬过去 10 年的数据。直接从官方网站下载历史全量的 CSV 数据包放入 S3 或本地,通过解析脚本做一次性历史回补(Backfill)。API 只用来做 T+1 或 T+7 的增量更新。
31
+ 2. **汇总与明细分层获取**:
32
+ 官方源(如美国 CBP)往往只提供商品、金额、重量等汇总维度。要拿带企业名的提单明细,必须对接商业第三方平台(如 PIERS、ImportGenius 等的接口或页面抓取)。
33
+ 3. **应对反爬保证数据不漏**:
34
+ 对于必须抓取网页的 B/C 类数据源,完善 `http_client.py` 中的动态代理池逻辑,使用高质量的住宅代理(Residential Proxies)避免被封 IP 导致数据缺失。
35
+
36
+ ## 4. 接下来的开发任务规划
37
+
38
+ 当前的首要任务是将系统从“Mock 跑通”推进到“真实数据落地”。开发任务及状态如下:
39
+
40
+ - [ ] **1. 将巴西改为真实数据源(首个真实闭环)**
41
+ - 修改 `packages/connectors/brazil.py` 中的 `fetch` 方法。
42
+ - 对接巴西 Comex Stat 真实的 API 或自动下载其公开的 CSV 文件。
43
+ - 完成真实葡语字段到标准模型的映射映射与验证。
44
+
45
+ - [ ] **2. 补全代理池与网络请求防封能力**
46
+ - 在 `packages/core/http_client.py` 中落实真实的代理 IP 获取逻辑(对接如快代理、BrightData 等)。
47
+ - 增加对 403 / 验证码 / Cloudflare 拦截的识别与重试机制。
48
+
49
+ - [ ] **3. 实现真实的历史数据回补(Backfill)**
50
+ - 修改 `apps/worker/run_backfill.py`。
51
+ - 设计一套读取本地或 S3 上的 CSV 文件并批量走 `parse -> save_raw -> normalize` 的流水线,先把巴西过去 3 年的数据灌入数据库。
52
+
53
+ - [ ] **4. 攻克第二个真实国家(建议智利或墨西哥)**
54
+ - 创建新的 Connector,复用巴西的成功经验,处理不同语言的字典映射。
55
+
56
+ - [ ] **5. 引入真实的分布式任务调度**
57
+ - 当前通过 `asyncio.run()` 单进程跑脚本无法满足后续多国家并发和失败重试。
58
+ - 规划引入 Celery 或 Prefect,将各个国家的 `run()` 注册为定时任务(Cron)和死信重试任务。
logs/app_2026-06-10.log ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 2026-06-10 22:39:48.907 | DEBUG | packages.core.http_client:request:53 - Request: GET https://balanca.economia.gov.br/balanca/bd/comexstat-bd/ncm/EXP_2026.csv | Attempt: 1/3
2
+ 2026-06-10 22:39:50.652 | WARNING | packages.core.http_client:request:77 - Request Error: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1081) for https://balanca.economia.gov.br/balanca/bd/comexstat-bd/ncm/EXP_2026.csv
3
+ 2026-06-10 22:39:52.707 | DEBUG | packages.core.http_client:request:53 - Request: GET https://balanca.economia.gov.br/balanca/bd/comexstat-bd/ncm/EXP_2026.csv | Attempt: 2/3
4
+ 2026-06-10 22:39:54.324 | WARNING | packages.core.http_client:request:77 - Request Error: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1081) for https://balanca.economia.gov.br/balanca/bd/comexstat-bd/ncm/EXP_2026.csv
5
+ 2026-06-10 22:39:58.422 | DEBUG | packages.core.http_client:request:53 - Request: GET https://balanca.economia.gov.br/balanca/bd/comexstat-bd/ncm/EXP_2026.csv | Attempt: 3/3
6
+ 2026-06-10 22:40:00.345 | WARNING | packages.core.http_client:request:77 - Request Error: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1081) for https://balanca.economia.gov.br/balanca/bd/comexstat-bd/ncm/EXP_2026.csv
7
+ 2026-06-10 22:40:08.353 | ERROR | packages.core.http_client:request:82 - Max retries reached for https://balanca.economia.gov.br/balanca/bd/comexstat-bd/ncm/EXP_2026.csv. Last error: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1081)
8
+ 2026-06-10 22:40:49.505 | DEBUG | packages.core.http_client:request:55 - Request: GET https://balanca.economia.gov.br/balanca/bd/comexstat-bd/ncm/EXP_2026.csv | Attempt: 1/3
9
+ 2026-06-10 22:40:51.104 | WARNING | packages.core.http_client:request:79 - Request Error: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1081) for https://balanca.economia.gov.br/balanca/bd/comexstat-bd/ncm/EXP_2026.csv
10
+ 2026-06-10 22:40:51.104 | WARNING | packages.core.http_client:request:82 - SSL 证书校验失败,降级为 verify=False 重试: https://balanca.economia.gov.br/balanca/bd/comexstat-bd/ncm/EXP_2026.csv
11
+ 2026-06-10 22:40:51.106 | DEBUG | packages.core.http_client:request:55 - Request: GET https://balanca.economia.gov.br/balanca/bd/comexstat-bd/ncm/EXP_2026.csv | Attempt: 2/3
packages/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (156 Bytes). View file
 
packages/connectors/__pycache__/base.cpython-314.pyc ADDED
Binary file (11 kB). View file
 
packages/connectors/__pycache__/brazil.cpython-314.pyc ADDED
Binary file (12.3 kB). View file
 
packages/connectors/brazil.py CHANGED
@@ -1,131 +1,193 @@
 
 
1
  import uuid
2
- from typing import List, Dict, Any
3
- from datetime import datetime
 
4
  from packages.connectors.base import BaseConnector
 
5
  from packages.core.models import RawTradeRecord, StandardTradeRecord
6
- from packages.dictionaries.brazil import CURRENCY_MAP, UNIT_MAP, TRANSPORT_MODE_MAP
7
- from packages.normalizers.company import clean_company_name
8
 
9
  class BrazilComexStatConnector(BaseConnector):
10
  """
11
- 巴西 Comex Stat 官方统计数据接入适配器
12
- 这是一个基于 API / CSV真实数据源结构模拟
13
  """
14
  country_code = "BR"
15
- source_system = "COMEX_STAT_API"
16
- parser_version = "v1.1.0"
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  async def discover(self) -> List[Any]:
19
- # 模拟发现 2024 年 5 月的数据批次
20
- return [{"year": 2024, "month": 5}]
 
 
 
 
 
 
21
 
22
  async def fetch(self, task_slice: Any) -> Dict[str, Any]:
23
- # 模拟调用巴西统计局接口返回的原始数据结构(葡萄牙语字段)
24
- # 真实情况这里会使用 self.http_client 进行 requests
25
- import random
26
- from datetime import datetime, timedelta
27
-
28
- random_days_ago_1 = random.randint(0, 365 * 10)
29
- simulated_date_1 = (datetime.now() - timedelta(days=random_days_ago_1)).strftime("%Y-%m-%d")
30
-
31
- random_days_ago_2 = random.randint(0, 365 * 10)
32
- simulated_date_2 = (datetime.now() - timedelta(days=random_days_ago_2)).strftime("%Y-%m-%d")
33
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  return {
35
  "metadata": {
36
  "status": "success",
37
- "period": f"{task_slice['year']}-{task_slice['month']:02d}"
 
 
38
  },
39
- "data": [
40
- {
41
- "id_registro": "BR-202405-001",
42
- "data_registro": simulated_date_1,
43
- "tipo_operacao": "IMPORTACAO", # 进口
44
- "cnpj_importador": "00.000.000/0001-91",
45
- "nome_importador": "BANCO DO BRASIL S.A.",
46
- "nome_exportador": "IBM CORPORATION",
47
- "ncm_codigo": "8471.30.12", # HS 编码在巴西叫 NCM
48
- "descricao_mercadoria": "MAQUINAS AUTOMATICAS PARA PROCESSAMENTO DE DADOS",
49
- "valor_fob": "1500000.00",
50
- "moeda": "DÓLAR",
51
- "peso_liquido": "2000",
52
- "unidade_medida": "QUILOGRAMA",
53
- "pais_origem": "US",
54
- "porto_desembarque": "SANTOS",
55
- "via_transporte": "MARÍTIMO"
56
- },
57
- {
58
- "id_registro": "BR-202405-002",
59
- "data_registro": simulated_date_2,
60
- "tipo_operacao": "EXPORTACAO", # 出口
61
- "cnpj_exportador": "11.111.111/0001-11",
62
- "nome_exportador": "VALE S.A.",
63
- "nome_importador": "BAOSTEEL GROUP",
64
- "ncm_codigo": "2601.11.00",
65
- "descricao_mercadoria": "MINERIOS DE FERRO E SEUS CONCENTRADOS",
66
- "valor_fob": "8500000.00",
67
- "moeda": "DÓLAR",
68
- "peso_liquido": "150000",
69
- "unidade_medida": "TONELADA",
70
- "pais_destino": "CN",
71
- "porto_embarque": "VITORIA",
72
- "via_transporte": "MARÍTIMO"
73
- }
74
- ]
75
  }
76
 
77
  async def parse(self, raw_data: Any) -> List[Dict[str, Any]]:
78
- # 实际的巴西接口返回的是一个字典 {"data": [...]},或者列表
79
  if isinstance(raw_data, dict):
80
  return raw_data.get("data", [])
81
- elif isinstance(raw_data, list):
82
  return raw_data
83
- else:
84
- return []
85
 
86
  async def normalize(self, raw: RawTradeRecord) -> StandardTradeRecord:
87
  data = raw.raw_json
88
-
89
- # 判断贸易方向
90
- op_type = data.get("tipo_operacao", "").upper()
91
- trade_direction = "import" if op_type == "IMPORTACAO" else "export"
92
-
93
- # 解析企业名并清洗
94
- if trade_direction == "import":
95
- importer = data.get("nome_importador", "")
96
- exporter = data.get("nome_exportador", "")
97
- else:
98
- importer = data.get("nome_importador", "")
99
- exporter = data.get("nome_exportador", "")
100
-
101
- importer_clean = clean_company_name(importer)
102
- exporter_clean = clean_company_name(exporter)
103
-
104
- # 字典映射
105
- currency = CURRENCY_MAP.get(data.get("moeda", "").upper(), "UNKNOWN")
106
- weight_unit = UNIT_MAP.get(data.get("unidade_medida", "").upper(), "UNKNOWN")
107
- transport_mode = TRANSPORT_MODE_MAP.get(data.get("via_transporte", "").upper(), "UNKNOWN")
108
-
109
- record_id = str(uuid.uuid4())
110
-
111
  return StandardTradeRecord(
112
  record_id=record_id,
113
  source_record_id=raw.id,
114
  batch_no=raw.batch_no,
115
  source_country=self.country_code,
116
  trade_direction=trade_direction,
117
- trade_date=datetime.strptime(data.get("data_registro"), "%Y-%m-%d"),
118
- importer_name=importer_clean,
119
- exporter_name=exporter_clean,
120
- hs_code=data.get("ncm_codigo", "").replace(".", ""), # 巴西 NCM 带点,标准化去掉
121
- product_name=data.get("descricao_mercadoria"),
122
- amount=float(data.get("valor_fob", 0)),
123
- currency=currency,
124
- weight=float(data.get("peso_liquido", 0)),
125
- weight_unit=weight_unit,
126
- origin_country=data.get("pais_origem") if trade_direction == "import" else self.country_code,
127
- destination_country=data.get("pais_destino") if trade_direction == "export" else self.country_code,
128
- departure_port=data.get("porto_embarque"),
129
- arrival_port=data.get("porto_desembarque"),
130
- transport_mode=transport_mode
131
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import csv
2
+ import io
3
  import uuid
4
+ from datetime import datetime, timezone
5
+ from typing import Any, Dict, List, Optional
6
+
7
  from packages.connectors.base import BaseConnector
8
+ from packages.core.http_client import http_client
9
  from packages.core.models import RawTradeRecord, StandardTradeRecord
10
+ from packages.dictionaries.brazil import TRANSPORT_MODE_MAP
 
11
 
12
  class BrazilComexStatConnector(BaseConnector):
13
  """
14
+ 巴西 Comex Stat 官方开放数据适配器
15
+ 当前优先接入官方年度 CSV,并按月过滤出真实记录。
16
  """
17
  country_code = "BR"
18
+ source_system = "COMEX_STAT_OPEN_DATA"
19
+ parser_version = "v1.2.0"
20
+ BASE_DATA_URL = "https://balanca.economia.gov.br/balanca/bd/comexstat-bd/ncm"
21
+
22
+ def __init__(
23
+ self,
24
+ session,
25
+ start_period: Optional[str] = None,
26
+ end_period: Optional[str] = None,
27
+ max_rows_per_slice: Optional[int] = None,
28
+ ):
29
+ super().__init__(session=session)
30
+ self.start_period = start_period
31
+ self.end_period = end_period
32
+ self.max_rows_per_slice = max_rows_per_slice
33
 
34
  async def discover(self) -> List[Any]:
35
+ periods = self._build_periods()
36
+ task_slices: List[Dict[str, Any]] = []
37
+
38
+ for year, month in periods:
39
+ task_slices.append({"year": year, "month": month, "trade_direction": "export"})
40
+ task_slices.append({"year": year, "month": month, "trade_direction": "import"})
41
+
42
+ return task_slices
43
 
44
  async def fetch(self, task_slice: Any) -> Dict[str, Any]:
45
+ year = int(task_slice["year"])
46
+ month = int(task_slice["month"])
47
+ trade_direction = task_slice["trade_direction"]
48
+ file_prefix = "EXP" if trade_direction == "export" else "IMP"
49
+ url = f"{self.BASE_DATA_URL}/{file_prefix}_{year}.csv"
50
+
51
+ response = await http_client.get(
52
+ url,
53
+ headers={"User-Agent": "customs-data/1.0"},
54
+ timeout=180,
55
+ follow_redirects=True,
56
+ allow_insecure_fallback=True,
57
+ )
58
+
59
+ text = response.content.decode("latin-1")
60
+ reader = csv.DictReader(io.StringIO(text), delimiter=";")
61
+ rows: List[Dict[str, Any]] = []
62
+
63
+ for row in reader:
64
+ if not row:
65
+ continue
66
+
67
+ row_month = self._safe_int(row.get("CO_MES"))
68
+ if row_month != month:
69
+ continue
70
+
71
+ rows.append(
72
+ {
73
+ "co_ano": row.get("CO_ANO"),
74
+ "co_mes": row.get("CO_MES"),
75
+ "co_ncm": row.get("CO_NCM"),
76
+ "co_unid": row.get("CO_UNID"),
77
+ "co_pais": row.get("CO_PAIS"),
78
+ "sg_uf_ncm": row.get("SG_UF_NCM"),
79
+ "co_via": row.get("CO_VIA"),
80
+ "co_urf": row.get("CO_URF"),
81
+ "qt_estat": row.get("QT_ESTAT"),
82
+ "kg_liquido": row.get("KG_LIQUIDO"),
83
+ "vl_fob": row.get("VL_FOB"),
84
+ "trade_direction": trade_direction,
85
+ "source_file_url": url,
86
+ }
87
+ )
88
+
89
+ if self.max_rows_per_slice and len(rows) >= self.max_rows_per_slice:
90
+ break
91
+
92
  return {
93
  "metadata": {
94
  "status": "success",
95
+ "period": f"{year}-{month:02d}",
96
+ "trade_direction": trade_direction,
97
+ "source_file_url": url,
98
  },
99
+ "data": rows,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  }
101
 
102
  async def parse(self, raw_data: Any) -> List[Dict[str, Any]]:
 
103
  if isinstance(raw_data, dict):
104
  return raw_data.get("data", [])
105
+ if isinstance(raw_data, list):
106
  return raw_data
107
+ return []
 
108
 
109
  async def normalize(self, raw: RawTradeRecord) -> StandardTradeRecord:
110
  data = raw.raw_json
111
+ trade_direction = data.get("trade_direction", "export")
112
+ trade_date = datetime(
113
+ year=self._safe_int(data.get("co_ano")) or 1970,
114
+ month=self._safe_int(data.get("co_mes")) or 1,
115
+ day=1,
116
+ )
117
+ ncm_code = (data.get("co_ncm") or "").strip()
118
+ partner_country = self._truncate(data.get("co_pais"), 10)
119
+ transport_mode = TRANSPORT_MODE_MAP.get((data.get("co_via") or "").strip().upper(), "UNKNOWN")
120
+ record_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{self.country_code}:{raw.id}"))
121
+
 
 
 
 
 
 
 
 
 
 
 
 
122
  return StandardTradeRecord(
123
  record_id=record_id,
124
  source_record_id=raw.id,
125
  batch_no=raw.batch_no,
126
  source_country=self.country_code,
127
  trade_direction=trade_direction,
128
+ trade_date=trade_date,
129
+ importer_name=None,
130
+ exporter_name=None,
131
+ hs_code=ncm_code,
132
+ product_name=f"NCM {ncm_code}" if ncm_code else None,
133
+ amount=self._safe_float(data.get("vl_fob")),
134
+ currency="USD",
135
+ weight=self._safe_float(data.get("kg_liquido")),
136
+ weight_unit="KG",
137
+ origin_country=partner_country if trade_direction == "import" else self.country_code,
138
+ destination_country=partner_country if trade_direction == "export" else self.country_code,
139
+ departure_port=self._truncate(data.get("co_urf"), 100) if trade_direction == "export" else None,
140
+ arrival_port=self._truncate(data.get("co_urf"), 100) if trade_direction == "import" else None,
141
+ transport_mode=transport_mode,
142
  )
143
+
144
+ def _build_periods(self) -> List[tuple[int, int]]:
145
+ if self.start_period and self.end_period:
146
+ return self._month_range(self.start_period, self.end_period)
147
+
148
+ now = datetime.now(timezone.utc)
149
+ year = now.year
150
+ month = now.month - 1
151
+ if month == 0:
152
+ year -= 1
153
+ month = 12
154
+ return [(year, month)]
155
+
156
+ def _month_range(self, start_period: str, end_period: str) -> List[tuple[int, int]]:
157
+ start = datetime.strptime(start_period, "%Y-%m")
158
+ end = datetime.strptime(end_period, "%Y-%m")
159
+ periods: List[tuple[int, int]] = []
160
+ current = start
161
+
162
+ while current <= end:
163
+ periods.append((current.year, current.month))
164
+ if current.month == 12:
165
+ current = current.replace(year=current.year + 1, month=1)
166
+ else:
167
+ current = current.replace(month=current.month + 1)
168
+
169
+ return periods
170
+
171
+ @staticmethod
172
+ def _safe_float(value: Optional[str]) -> Optional[float]:
173
+ if value in (None, ""):
174
+ return None
175
+ try:
176
+ return float(str(value).replace(",", "."))
177
+ except (TypeError, ValueError):
178
+ return None
179
+
180
+ @staticmethod
181
+ def _safe_int(value: Optional[str]) -> Optional[int]:
182
+ if value in (None, ""):
183
+ return None
184
+ try:
185
+ return int(str(value).strip())
186
+ except (TypeError, ValueError):
187
+ return None
188
+
189
+ @staticmethod
190
+ def _truncate(value: Optional[str], max_length: int) -> Optional[str]:
191
+ if not value:
192
+ return None
193
+ return str(value).strip()[:max_length]
packages/core/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (161 Bytes). View file
 
packages/core/__pycache__/config.cpython-314.pyc ADDED
Binary file (1.61 kB). View file
 
packages/core/__pycache__/database.cpython-314.pyc ADDED
Binary file (1.48 kB). View file
 
packages/core/__pycache__/http_client.cpython-314.pyc ADDED
Binary file (6.18 kB). View file
 
packages/core/__pycache__/logger.cpython-314.pyc ADDED
Binary file (928 Bytes). View file
 
packages/core/__pycache__/models.cpython-314.pyc ADDED
Binary file (4.23 kB). View file
 
packages/core/http_client.py CHANGED
@@ -39,14 +39,19 @@ class BaseHttpClient:
39
 
40
  attempt = 0
41
  last_exception = None
 
 
42
 
43
  while attempt < self.max_retries:
44
  attempt += 1
45
  proxy = await self._get_proxy()
46
- proxies = {"http://": proxy, "https://": proxy} if proxy else None
47
 
48
  try:
49
- async with httpx.AsyncClient(proxies=proxies, timeout=self.timeout) as client:
 
 
 
 
50
  app_logger.debug(f"Request: {method} {url} | Attempt: {attempt}/{self.max_retries}")
51
  response = await client.request(
52
  method=method,
@@ -73,6 +78,10 @@ class BaseHttpClient:
73
  except httpx.RequestError as e:
74
  app_logger.warning(f"Request Error: {str(e)} for {url}")
75
  last_exception = e
 
 
 
 
76
  await asyncio.sleep(2 ** attempt)
77
  continue
78
 
 
39
 
40
  attempt = 0
41
  last_exception = None
42
+ verify = kwargs.pop("verify", True)
43
+ allow_insecure_fallback = kwargs.pop("allow_insecure_fallback", False)
44
 
45
  while attempt < self.max_retries:
46
  attempt += 1
47
  proxy = await self._get_proxy()
 
48
 
49
  try:
50
+ client_kwargs = {"timeout": self.timeout, "verify": verify}
51
+ if proxy:
52
+ client_kwargs["proxy"] = proxy
53
+
54
+ async with httpx.AsyncClient(**client_kwargs) as client:
55
  app_logger.debug(f"Request: {method} {url} | Attempt: {attempt}/{self.max_retries}")
56
  response = await client.request(
57
  method=method,
 
78
  except httpx.RequestError as e:
79
  app_logger.warning(f"Request Error: {str(e)} for {url}")
80
  last_exception = e
81
+ if allow_insecure_fallback and verify and "CERTIFICATE_VERIFY_FAILED" in str(e):
82
+ app_logger.warning(f"SSL 证书校验失败,降级为 verify=False 重试: {url}")
83
+ verify = False
84
+ continue
85
  await asyncio.sleep(2 ** attempt)
86
  continue
87
 
packages/dictionaries/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (169 Bytes). View file
 
packages/dictionaries/__pycache__/brazil.cpython-314.pyc ADDED
Binary file (554 Bytes). View file