Skip to content

Commit 52afdba

Browse files
authored
fix: harden fetch transactions and prepare v0.4.1 (#65)
1 parent fe3576a commit 52afdba

13 files changed

Lines changed: 398 additions & 135 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: Optional dependencies
2+
3+
on:
4+
workflow_dispatch:
5+
schedule:
6+
- cron: "17 3 * * 1"
7+
8+
jobs:
9+
install:
10+
runs-on: ubuntu-latest
11+
timeout-minutes: 35
12+
strategy:
13+
fail-fast: false
14+
matrix:
15+
extra: [base, viz, ml]
16+
steps:
17+
- uses: actions/checkout@v4
18+
- uses: actions/setup-python@v5
19+
with:
20+
python-version: "3.12"
21+
cache: pip
22+
- name: Install base
23+
if: matrix.extra == 'base'
24+
run: python -m pip install -e .
25+
- name: Install optional extra
26+
if: matrix.extra != 'base'
27+
run: python -m pip install -e ".[${{ matrix.extra }}]"
28+
- name: Smoke test base CLI
29+
run: |
30+
xcrawler --version
31+
xcrawler demo --output /tmp/xcrawler-demo
32+
- name: Smoke test viz imports
33+
if: matrix.extra == 'viz'
34+
run: python -c "import matplotlib; import visualize"
35+
- name: Smoke test ML imports
36+
if: matrix.extra == 'ml'
37+
run: python -c "import sentence_transformers; import sklearn"

.github/workflows/publish.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: Publish to PyPI
2+
3+
on:
4+
release:
5+
types: [published]
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
build:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
- uses: actions/setup-python@v5
16+
with:
17+
python-version: "3.12"
18+
- name: Build distributions
19+
run: |
20+
python -m pip install --upgrade build
21+
python -m build
22+
- uses: actions/upload-artifact@v4
23+
with:
24+
name: python-package-distributions
25+
path: dist/
26+
27+
publish:
28+
needs: build
29+
runs-on: ubuntu-latest
30+
environment:
31+
name: pypi
32+
url: https://pypi.org/project/xcrawler-ai/
33+
permissions:
34+
id-token: write
35+
steps:
36+
- uses: actions/download-artifact@v4
37+
with:
38+
name: python-package-distributions
39+
path: dist/
40+
- uses: pypa/gh-action-pypi-publish@release/v1

.github/workflows/test.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,21 @@ jobs:
5858
/tmp/xcrawler-wheel-test/bin/python -m pip install dist/*.whl
5959
/tmp/xcrawler-wheel-test/bin/xcrawler --version
6060
/tmp/xcrawler-wheel-test/bin/xcrawler --help
61+
62+
coverage:
63+
runs-on: ubuntu-latest
64+
steps:
65+
- uses: actions/checkout@v4
66+
- uses: actions/setup-python@v5
67+
with:
68+
python-version: "3.12"
69+
- name: Install test dependencies
70+
run: python -m pip install -e ".[test]"
71+
- name: Enforce coverage floor
72+
run: >-
73+
python -m pytest
74+
--cov=xcrawler
75+
--cov=main
76+
--cov=fetch_more_history
77+
--cov-report=term-missing
78+
--cov-fail-under=80

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.4.1] - 2026-07-12
11+
1012
### Fixed
1113
- Full timeline fetching now retries transient failures and fails explicitly when any later page cannot be fetched, instead of returning a partial result as complete
1214
- `xcrawler fetch --replace` commits raw and translated snapshots together and preserves both previous files when translation is incomplete or either replacement fails
@@ -19,6 +21,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1921
### Changed
2022
- `fetch-more --pages` is now explicitly a shared HTTP request budget that includes retries
2123
- Full fetch behavior is documented as archive mode by default and snapshot mode with `--replace`
24+
- Full and incremental X API operations share one retry, rate-limit, and error-classification engine
25+
- `--replace --no-translate` filters retained translations to the new raw snapshot instead of leaving stale records
26+
- CI now enforces a coverage floor and performs scheduled base, visualization, and ML installation smoke tests
27+
- The PyPI distribution is named `xcrawler-ai` (the CLI and import package remain `xcrawler`) and uses Trusted Publishing
2228

2329
## [0.4.0] - 2026-07-11
2430

README.md

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
- 🧩 **模块化** — 统一 `xcrawler` CLI,支持可插拔存储和 LLM Provider
2323

2424
```bash
25-
python3 -m pip install -e ".[all]" # 安装全功能依赖
25+
python3 -m pip install "xcrawler-ai[all]" # 从 PyPI 安装全功能依赖
2626
xcrawler demo # 无需 API Key 的虚构数据示例
2727
xcrawler fetch --user MiracleHe # 抓取 + 翻译 + 聚类
2828
xcrawler analyze interest --user MiracleHe # 专业兴趣画像
@@ -64,15 +64,15 @@ xcrawler report --user MiracleHe # 生成图表 + HTML 报告
6464
项目要求 Python 3.10 或更高版本。
6565

6666
```bash
67-
# 推荐:使用虚拟环境,安装全功能依赖
67+
# 推荐:使用虚拟环境,从 PyPI 安装全功能依赖
6868
python3 -m venv .venv
6969
source .venv/bin/activate
70-
python3 -m pip install -e ".[all]"
70+
python3 -m pip install "xcrawler-ai[all]"
7171

72-
# 如果只需要基础 CLI / 抓取 / LLM 功能
73-
python3 -m pip install -e .
72+
# 如果只需要基础 CLI / 抓取 / LLM 功能(命令仍为 xcrawler)
73+
python3 -m pip install xcrawler-ai
7474

75-
# 可选依赖:向量聚类/可视化
75+
# 从源码开发或按需安装向量聚类/可视化依赖
7676
python3 -m pip install -e ".[ml,viz]"
7777
```
7878

@@ -1006,7 +1006,13 @@ LLM 调用通过 `LLMProvider` 抽象保留 DeepSeek/OpenAI 兼容 Provider 入
10061006

10071007
## 🔄 更新日志
10081008

1009-
### v0.4.0 - 可靠性、安全数据语义与无密钥 Demo 🆕
1009+
### v0.4.1 - 抓取事务与增量状态机 🆕
1010+
-**全量完整性**:任意分页失败都显式返回失败,禁止部分结果进入 snapshot
1011+
-**增量状态机**:Forward/Backward 分阶段保存,记录请求、数据页、重试、stop reason 和 partial 状态
1012+
-**数据契约**:raw schema 严格校验,译文携带原文与配置指纹
1013+
-**统一请求引擎**:全量、增量、用户信息共用重试、限流和错误分类
1014+
1015+
### v0.4.0 - 可靠性、安全数据语义与无密钥 Demo
10101016
-**抓取可靠性**:增量抓取区分“无新数据”和网络失败,增加 429/5xx/超时重试
10111017
-**数据安全**:全量抓取默认合并,`--replace` 显式覆盖,强制重翻改为全有或全无
10121018
-**快照事务**`--replace` 仅在抓取完整且翻译全部成功后同时提交 raw/translated,部分失败返回非零退出码

RELEASE_CHECKLIST.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
- [ ] `xcrawler --help` 可正常显示。
1212
- [ ] `python3 -m build` 成功生成 wheel 和 sdist。
1313
- [ ] `python3 -m twine check dist/*` 通过。
14+
- [ ] PyPI Trusted Publisher 指向 `yuanrengu/xcrawler`、工作流 `publish.yml`、环境 `pypi`
1415
- [ ] 在干净虚拟环境从 wheel 安装后,`xcrawler --version``xcrawler --help``xcrawler demo` 通过。
1516
- [ ] `README.md``QUICK_START.md``.env.example` 与当前 CLI 保持一致。
1617
- [ ] `CONTRIBUTING.md``SECURITY.md``LICENSE` 存在且链接有效。
@@ -22,13 +23,14 @@
2223
- [ ] 更新 `pyproject.toml` 中的版本号。
2324
- [ ] `xcrawler/__init__.py``pyproject.toml` 版本号一致。
2425
- [ ] `CHANGELOG.md` 将待发布内容从 `Unreleased` 移入当前版本和日期。
25-
- [ ] 创建并验证签名或 annotated Git tag,例如 `v0.4.0`
26+
- [ ] 创建并验证签名或 annotated Git tag,例如 `v0.4.1`
2627
- [ ] 在 README 更新对应版本的变更摘要。
27-
- [ ] 创建 GitHub Release,标题使用版本号,例如 `v0.4.0`
28+
- [ ] 创建 GitHub Release,标题使用版本号,例如 `v0.4.1`
2829
- [ ] Release notes 包含:新增能力、兼容性说明、迁移提示、已知限制。
2930

3031
## 发布后
3132

3233
- [ ] 确认 Release 页面、源码包和文档链接可访问。
34+
- [ ] GitHub Release 发布后,`publish.yml` 成功将 `xcrawler-ai` 上传到 PyPI。
3335
- [ ] 创建下一阶段 roadmap 或 follow-up issues。
3436
- [ ] 如涉及安全或隐私变更,在 release notes 中单独说明。

fetch_more_history.py

Lines changed: 48 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
from __future__ import annotations
88

99
import argparse
10-
import json
1110
import os
1211
import time
1312
from dataclasses import asdict, dataclass
@@ -16,6 +15,7 @@
1615
import requests
1716

1817
from xcrawler.clients import x_api
18+
from xcrawler.clients.retry import RequestAttemptsError, request_json_with_retries
1919
from xcrawler.clients.x_api import auth_headers
2020
from xcrawler.config import load_config
2121
from xcrawler.paths import ensure_dir
@@ -42,6 +42,12 @@
4242
class FetchError(RuntimeError):
4343
"""抓取未能成功完成,不应将其视为“没有新数据”。"""
4444

45+
def __init__(self, message: str, *, requests_used: int = 0, retries: int = 0, stop_reason: str = "error"):
46+
super().__init__(message)
47+
self.requests_used = requests_used
48+
self.retries = retries
49+
self.stop_reason = stop_reason
50+
4551

4652
@dataclass
4753
class FetchBatchResult:
@@ -87,6 +93,9 @@ def save_fetch_status(
8793
forward: FetchBatchResult | None = None,
8894
backward: FetchBatchResult | None = None,
8995
error: str | None = None,
96+
error_requests: int = 0,
97+
error_retries: int = 0,
98+
error_stop_reason: str | None = None,
9099
) -> None:
91100
save_json(
92101
os.path.join(cache_dir, f"{username}_fetch_status.json"),
@@ -99,12 +108,13 @@ def save_fetch_status(
99108
"backward": backward.to_dict() if backward else None,
100109
"requests_used": (forward.requests_used if forward else 0) + (
101110
backward.requests_used if backward else 0
102-
),
111+
) + error_requests,
103112
"data_pages": (forward.data_pages if forward else 0) + (
104113
backward.data_pages if backward else 0
105114
),
106-
"retries": (forward.retries if forward else 0) + (backward.retries if backward else 0),
115+
"retries": (forward.retries if forward else 0) + (backward.retries if backward else 0) + error_retries,
107116
"error": error,
117+
"error_stop_reason": error_stop_reason,
108118
},
109119
)
110120

@@ -160,63 +170,29 @@ def fetch_tweets_generic(
160170

161171
page = 0
162172
while data_pages < max_pages_limit and requests_used < request_budget:
163-
response = None
164-
data = None
165-
for attempt in range(1, max_retries + 1):
166-
if requests_used >= request_budget:
167-
raise FetchError(f"请求预算已用完({requests_used}/{request_budget})")
168-
try:
169-
requests_used += 1
170-
response = requests.get(url, headers=headers, params=params, timeout=10)
171-
if response.status_code == 429:
172-
reset_time = response.headers.get("x-rate-limit-reset")
173-
if reset_time is None:
174-
wait_seconds = min(2 ** (attempt - 1), 8)
175-
else:
176-
try:
177-
wait_seconds = max(0, int(float(reset_time)) - int(time.time()))
178-
except (TypeError, ValueError) as error:
179-
raise FetchError("API 限流重置时间无效") from error
180-
if wait_seconds > MAX_RATE_LIMIT_WAIT:
181-
raise FetchError(f"API 限流需等待 {wait_seconds} 秒,超过最大等待时间")
182-
if attempt == max_retries:
183-
raise FetchError(f"API 限流,重试 {max_retries} 次后仍未恢复")
184-
print(f"⏳ API 限流,{wait_seconds} 秒后重试 ({attempt}/{max_retries})...")
185-
retries += 1
186-
time.sleep(wait_seconds)
187-
continue
188-
189-
response.raise_for_status()
190-
data = response.json()
191-
break
192-
except requests.exceptions.HTTPError as error:
193-
status = error.response.status_code if error.response is not None else 0
194-
if status in (401, 403):
195-
raise FetchError(f"认证失败(HTTP {status}),请检查 X_BEARER_TOKEN") from error
196-
retryable = status >= 500 or status == 0
197-
if not retryable or attempt == max_retries:
198-
raise FetchError(f"第 {page + 1} 页 HTTP 错误({status or 'unknown'})") from error
199-
delay = min(2 ** (attempt - 1), 8)
200-
print(f"⚠️ 第 {page + 1} 页 HTTP {status}{delay} 秒后重试 ({attempt}/{max_retries})...")
201-
retries += 1
202-
time.sleep(delay)
203-
except requests.exceptions.RequestException as error:
204-
if attempt == max_retries:
205-
raise FetchError(f"第 {page + 1} 页网络错误,重试 {max_retries} 次后仍失败") from error
206-
delay = min(2 ** (attempt - 1), 8)
207-
print(f"⚠️ 第 {page + 1} 页网络错误,{delay} 秒后重试 ({attempt}/{max_retries})...")
208-
retries += 1
209-
time.sleep(delay)
210-
except (json.JSONDecodeError, ValueError) as error:
211-
if attempt == max_retries:
212-
raise FetchError(f"第 {page + 1} 页响应解析失败") from error
213-
delay = min(2 ** (attempt - 1), 8)
214-
print(f"⚠️ 第 {page + 1} 页响应解析失败,{delay} 秒后重试 ({attempt}/{max_retries})...")
215-
retries += 1
216-
time.sleep(delay)
217-
218-
if response is None or data is None:
219-
raise FetchError(f"第 {page + 1} 页未能获取有效响应")
173+
try:
174+
attempt_result = request_json_with_retries(
175+
url,
176+
headers=headers,
177+
params=params,
178+
request_get=requests.get,
179+
max_retries=max_retries,
180+
request_budget=request_budget - requests_used,
181+
page_number=page + 1,
182+
max_rate_limit_wait=MAX_RATE_LIMIT_WAIT,
183+
sleep=time.sleep,
184+
)
185+
except RequestAttemptsError as error:
186+
raise FetchError(
187+
str(error),
188+
requests_used=requests_used + error.requests_used,
189+
retries=retries + error.retries,
190+
stop_reason=error.stop_reason,
191+
) from error
192+
requests_used += attempt_result.requests_used
193+
retries += attempt_result.retries
194+
response = attempt_result.response
195+
data = attempt_result.data
220196

221197
try:
222198

@@ -387,7 +363,15 @@ def main():
387363
)
388364
except FetchError as error:
389365
print(f"❌ 新推文抓取失败: {error}")
390-
save_fetch_status(CACHE_DIR, TARGET_USERNAME, status="failed", error=str(error))
366+
save_fetch_status(
367+
CACHE_DIR,
368+
TARGET_USERNAME,
369+
status="failed",
370+
error=str(error),
371+
error_requests=error.requests_used,
372+
error_retries=error.retries,
373+
error_stop_reason=error.stop_reason,
374+
)
391375
return 1
392376
remaining_pages_quota -= forward_result.requests_used
393377
tweets = forward_result.tweets
@@ -444,6 +428,9 @@ def main():
444428
status="partial",
445429
forward=forward_result,
446430
error=str(error),
431+
error_requests=error.requests_used,
432+
error_retries=error.retries,
433+
error_stop_reason=error.stop_reason,
447434
)
448435
return 2
449436
tweets = backward_result.tweets

main.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from xcrawler.paths import ensure_dir, translation_cache_path
1515
from xcrawler.services.embeddings import encode_texts_with_cache
1616
from xcrawler.services.llm_calls import LLMCallRecorder
17-
from xcrawler.services.records import make_translated_tweet
17+
from xcrawler.services.records import make_translated_tweet, normalize_translated_tweets
1818
from xcrawler.services.sampling import sample_evenly
1919
from xcrawler.services.translation import (
2020
translate_batch,
@@ -329,7 +329,22 @@ def main():
329329
# 3. 清洗 + 批量翻译
330330
if args.no_translate:
331331
if args.replace:
332-
save_json(raw_file, raw_tweets)
332+
translated_file = os.path.join(CACHE_DIR, f"{TARGET_USERNAME}_translated.json")
333+
if os.path.exists(translated_file):
334+
existing_translated = normalize_translated_tweets(load_json(translated_file, default=[]))
335+
snapshot_ids = {str(tweet["id"]) for tweet in raw_tweets}
336+
retained_translations = [
337+
item
338+
for item in existing_translated
339+
if item.get("tweet_id") is not None and str(item["tweet_id"]) in snapshot_ids
340+
]
341+
replace_json_files_atomically({
342+
raw_file: raw_tweets,
343+
translated_file: retained_translations,
344+
})
345+
print(f"🧹 snapshot 已移除 {len(existing_translated) - len(retained_translations)} 条过期译文")
346+
else:
347+
save_json(raw_file, raw_tweets)
333348
print("⏭️ --no-translate 模式:跳过翻译和分析,仅保存原始推文")
334349
print(f"\n✅ 完成!原始推文已保存至: {raw_file}")
335350
return 0

0 commit comments

Comments
 (0)