Spaces:
Runtime error
Runtime error
feat: 接入巴西真实海关数据源并优化回补逻辑与HTTP客户端
Browse files- 实现巴西Comex Stat官方开放CSV数据源的拉取、解析与标准化流程,替换原Mock模拟数据
- 优化回补脚本run_backfill.py,支持巴西按年月范围批量执行回补任务
- 修复HTTP客户端SSL证书校验失败的降级重试逻辑,新增代理与SSL参数配置能力
- 添加项目下一步开发规划文档与测试日志示例
- apps/worker/__pycache__/run_backfill.cpython-314.pyc +0 -0
- apps/worker/run_backfill.py +12 -2
- docs/下一步开发规划与数据扩充方案.md +58 -0
- logs/app_2026-06-10.log +11 -0
- packages/__pycache__/__init__.cpython-314.pyc +0 -0
- packages/connectors/__pycache__/base.cpython-314.pyc +0 -0
- packages/connectors/__pycache__/brazil.cpython-314.pyc +0 -0
- packages/connectors/brazil.py +161 -99
- packages/core/__pycache__/__init__.cpython-314.pyc +0 -0
- packages/core/__pycache__/config.cpython-314.pyc +0 -0
- packages/core/__pycache__/database.cpython-314.pyc +0 -0
- packages/core/__pycache__/http_client.cpython-314.pyc +0 -0
- packages/core/__pycache__/logger.cpython-314.pyc +0 -0
- packages/core/__pycache__/models.cpython-314.pyc +0 -0
- packages/core/http_client.py +11 -2
- packages/dictionaries/__pycache__/__init__.cpython-314.pyc +0 -0
- packages/dictionaries/__pycache__/brazil.cpython-314.pyc +0 -0
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
|
| 3 |
-
from
|
|
|
|
| 4 |
from packages.connectors.base import BaseConnector
|
|
|
|
| 5 |
from packages.core.models import RawTradeRecord, StandardTradeRecord
|
| 6 |
-
from packages.dictionaries.brazil import
|
| 7 |
-
from packages.normalizers.company import clean_company_name
|
| 8 |
|
| 9 |
class BrazilComexStatConnector(BaseConnector):
|
| 10 |
"""
|
| 11 |
-
巴西 Comex Stat 官方
|
| 12 |
-
|
| 13 |
"""
|
| 14 |
country_code = "BR"
|
| 15 |
-
source_system = "
|
| 16 |
-
parser_version = "v1.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
async def discover(self) -> List[Any]:
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
async def fetch(self, task_slice: Any) -> Dict[str, Any]:
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
return {
|
| 35 |
"metadata": {
|
| 36 |
"status": "success",
|
| 37 |
-
"period": f"{
|
|
|
|
|
|
|
| 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 |
-
|
| 82 |
return raw_data
|
| 83 |
-
|
| 84 |
-
return []
|
| 85 |
|
| 86 |
async def normalize(self, raw: RawTradeRecord) -> StandardTradeRecord:
|
| 87 |
data = raw.raw_json
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 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=
|
| 118 |
-
importer_name=
|
| 119 |
-
exporter_name=
|
| 120 |
-
hs_code=
|
| 121 |
-
product_name=
|
| 122 |
-
amount=
|
| 123 |
-
currency=
|
| 124 |
-
weight=
|
| 125 |
-
weight_unit=
|
| 126 |
-
origin_country=
|
| 127 |
-
destination_country=
|
| 128 |
-
departure_port=data.get("
|
| 129 |
-
arrival_port=data.get("
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|