本文档说明垂直视频查询 API 的调用协议、参数规则、数据库查询逻辑、去重与分页语义、错误处理、日志、部署和性能注意事项。
| 项目 | 内容 |
|---|---|
| 业务接口 | POST /api/v1/crawler/videos/query |
| 请求格式 | application/json |
| 鉴权 | 接口自身不校验 Token;公网访问由 Nginx 控制 |
| 时间标准 | 东八区(UTC+8) |
| 默认时间范围 | 最近 3 天 |
| 默认页大小 | 500 条 |
| 最大页大小 | 由 API_MAX_LIMIT 控制,默认 500 条 |
| 分页方式 | 基于去重结果最大 id 的游标分页 |
| 去重方式 | 相同 out_video_id 保留最大 id 对应的记录 |
| 数据库操作 | 只读,不修改数据库结构和数据 |
此外提供两个运维探针:
| 路径 | 说明 |
|---|---|
GET /health |
进程存活检查,不访问数据库 |
GET /ready |
就绪检查,执行 SELECT 1 AS ok 验证数据库连接 |
探针由 Nginx 示例限制为仅本机访问,不应暴露为公网业务接口。
业务接口自身不校验 Token。公网部署时应在 Nginx 层限制新加坡调度服务器的固定出口 IP,
并启用请求速率和连接数限制;不要将后端 8888 端口直接暴露到公网。
调用方可以传入:
X-Request-ID: scheduler-task-123-page-1
允许字符为字母、数字、点、下划线和短横线,最长 128 个字符。未传或格式不正确时,API 自动生成 UUID。
响应头会返回同一个 X-Request-ID。本地日志和阿里云 SLS 也记录该值,可用于串联调度日志、API 日志和数据库异常。
请求体为 JSON 对象。未定义字段会被拒绝,避免拼写错误被静默忽略。
| 参数 | 类型 | 必填 | 默认值 | 限制与说明 |
|---|---|---|---|---|
platforms |
string[] |
否 | xiaoniangao、xiaoniangaotuijianliu |
只支持这两个平台;1~20 个;单项最长 50;自动去除首尾空格和重复值 |
keywords |
string[] |
否 | [] |
最多 20 个;单项最长 200;按标题模糊匹配 |
filter_match_mode |
integer |
否 | 2 |
1 表示过滤条件 OR;2 表示过滤条件 AND |
filters |
object[] |
否 | [] |
最多 50 个结构化过滤条件 |
limit |
integer |
否 | 500 |
范围为 1~API_MAX_LIMIT;超出范围直接返回参数错误 |
cursor |
object |
否 | null |
第一页不传;下一页原样传回响应的 next_cursor |
创建时间和其他筛选字段一样放在 filters 中,field 固定为 create_time。时间值支持:
178698240000017869824002026-08-18 00:00:00 或 2026-08-18T00:00:00时间戳统一按东八区转换为数据库查询时间。
单边或双边范围示例:
"filters": [
{"field": "create_time", "operator": ">=", "value": 1786896000000},
{"field": "create_time", "operator": "<", "value": 1786982400000}
]
完全没有 create_time 条件时,API 默认查询当前时间之前3天。多条创建时间条件固定使用 AND,不受 filter_match_mode 影响。默认时间范围的首屏时间锚点会写入 next_cursor.query_time,下一页原样回传游标即可保持范围不漂移。
关键词只匹配 video_title,每个关键词使用:
video_title LIKE '%关键词%'
多个关键词之间固定使用 OR。例如:
{"keywords": ["早", "养生"]}
等价于:
(video_title LIKE '%早%' OR video_title LIKE '%养生%')
关键词条件与平台、创建时间、视频地址和 filters 之间使用 AND。
%关键词% 无法有效利用普通 B-Tree 索引,短关键词或高频词可能扫描较多数据。
每个过滤条件格式为:
{
"field": "like_cnt",
"operator": ">",
"value": 3
}
每个 filters 元素严格只允许以下三个 key:
| key | 必填 | 说明 |
|---|---|---|
field |
是 | 白名单过滤字段 |
operator |
是 | 白名单比较操作符 |
value |
是 | 与操作符匹配的单值或数组 |
缺少任意 key 会返回“不能为空”;增加 column、sql、name 等任何未知 key 会返回 400。例如:
参数校验失败: 不支持的参数: filters.0.column
参数校验失败: filters.0.operator不能为空
允许字段:
| 字段 | 含义 | 值类型 |
|---|---|---|
play_cnt |
播放数 | 数字 |
like_cnt |
点赞数 | 数字 |
share_cnt |
分享数 | 数字 |
collection_cnt |
收藏数 | 数字 |
comment_cnt |
评论数 | 数字 |
duration |
视频时长,单位秒 | 数字 |
publish_time |
视频发布时间 | 时间或时间戳 |
create_time |
数据创建时间 | 时间或时间戳 |
允许操作符:
| 操作符 | value 要求 |
示例 |
|---|---|---|
> |
单值 | {"field":"like_cnt","operator":">","value":3} |
>= |
单值 | {"field":"play_cnt","operator":">=","value":1000} |
= |
单值 | {"field":"duration","operator":"=","value":60} |
< |
单值 | {"field":"duration","operator":"<","value":60} |
<= |
单值 | {"field":"comment_cnt","operator":"<=","value":100} |
between |
恰好两个值的数组,包含边界 | {"field":"duration","operator":"between","value":[60,300]} |
in |
非空数组 | {"field":"play_cnt","operator":"in","value":[100,500]} |
not_in |
非空数组 | {"field":"duration","operator":"not_in","value":[0,1]} |
数字字段拒绝布尔值、NaN、正负无穷、SQL 片段和不能转换为有限数字的字符串。字段名和操作符均为白名单,所有值都通过 SQL 参数绑定,不直接拼接到 SQL。
between 的起始值不能大于结束值;in、not_in 以及其他数组型筛选值最多允许 100 项。过滤条件对象中的未知字段也会被拒绝。
publish_time 当前数据库类型为 varchar(100),API 会把时间值格式化成 YYYY-MM-DD HH:MM:SS 后比较。数据库中的历史值需要保持相同且可排序的格式,否则筛选结果可能不准确。本接口不修改数据库字段类型。
"filter_match_mode": 2
1:除 create_time 外的过滤条件使用 OR2:除 create_time 外的过滤条件使用 AND,默认值例如:
{
"filter_match_mode": 2,
"filters": [
{"field": "like_cnt", "operator": ">", "value": 3},
{"field": "duration", "operator": "between", "value": [60, 300]}
]
}
对应:
like_cnt > 3 AND duration BETWEEN 60 AND 300
filter_match_mode 不改变关键词之间的 OR 规则。同一字段的多条条件始终使用 AND,
因此 play_cnt >= 100 和 play_cnt <= 1000 会组成播放量区间;不同字段之间才应用
filter_match_mode。多条 create_time 条件也始终使用 AND,并作为整个查询的时间范围。
第一页不传 cursor:
{
"keywords": ["早"],
"limit": 500
}
如果响应 has_more=true,下一页原样传入:
{
"keywords": ["早"],
"limit": 500,
"cursor": {
"id": 6815000,
"query_time": 1786982400000
}
}
cursor.id 必须是大于 0 的整数。query_time 仅在使用默认近3天范围时返回。不要自行修改游标,也不要在分页过程中修改其他筛选参数。
{
"platforms": ["xiaoniangao", "xiaoniangaotuijianliu"],
"keywords": ["早", "养生"],
"filter_match_mode": 2,
"filters": [
{"field": "create_time", "operator": ">=", "value": 1786896000000},
{"field": "create_time", "operator": "<", "value": 1786982400000},
{"field": "play_cnt", "operator": ">=", "value": 1000},
{"field": "like_cnt", "operator": ">", "value": 3},
{"field": "duration", "operator": "between", "value": [60, 300]},
{"field": "publish_time", "operator": ">=", "value": 1785772800000}
],
"limit": 500
}
不支持的平台:
{
"platforms": ["douyin"]
}
返回:
{
"code": 400,
"msg": "参数校验失败: platforms: 不支持的平台: douyin",
"data": [
{
"type": "value_error",
"loc": ["platforms"]
}
]
}
不支持的顶层参数:
{
"page_index": 2
}
返回消息包含:
参数校验失败: 不支持的参数: page_index
不支持的过滤字段或操作符会指出完整路径,例如:
参数校验失败: filters.0.field不支持值 unknown_field
参数校验失败: filters.0.operator不支持值 contains
查询依次应用以下规则:
platform IN (...)create_time 条件;未提供时限定为最近3天video_url <> ''filter_match_mode 组合out_video_id 去重id 游标核心 SQL 形态:
SELECT cv.<视频字段>
FROM crawler_video AS cv
INNER JOIN (
SELECT MAX(source.id) AS selected_id
FROM crawler_video AS source
WHERE <平台、时间、视频地址、关键词、过滤条件>
GROUP BY
CASE
WHEN source.out_video_id = '' THEN source.id
ELSE 0
END,
source.out_video_id
HAVING MAX(source.id) < :cursor_id
ORDER BY selected_id DESC
LIMIT :page_size_plus_one
) AS dedup ON dedup.selected_id = cv.id
ORDER BY cv.id DESC;
第一页没有 HAVING 条件。
out_video_id 只保留 id 最大的一条,即最后入库记录。out_video_id='' 的记录按各自 id 分组,因此不会全部合并成一条。id/最后入库”,不是严格比较 publish_time。id DESC。HAVING MAX(id) < cursor.id 应用于分组后的结果。id 大于当前游标的数据不会插入到本轮分页中间。{
"code": 0,
"msg": "",
"data": {
"data": [
{
"video_id": 10001,
"user_id": 20001,
"out_user_id": "external-user-id",
"platform": "xiaoniangao",
"strategy": "recommend",
"out_video_id": "external-video-id",
"video_title": "早上好",
"cover_url": "https://example.com/cover.jpg",
"video_url": "https://example.com/video.mp4",
"duration": 60,
"publish_time": "2026-08-18 08:00:00",
"play_cnt": 1000,
"like_cnt": 20,
"share_cnt": 2,
"collection_cnt": 3,
"comment_cnt": 5,
"width": 1080,
"height": 1920,
"id": 6815000,
"create_time": "2026-08-18 08:10:00"
}
],
"count": 1,
"has_more": true,
"next_cursor": {"id": 6815000, "query_time": 1786982400000}
}
}
字段说明:
| 字段 | 说明 |
|---|---|
code |
业务码;成功固定为 0 |
msg |
成功时为空字符串 |
data.data |
当前页视频数组 |
data.count |
当前页实际返回数量,不包含额外探测行 |
data.has_more |
是否存在下一页 |
data.next_cursor |
下一页游标;没有下一页时为 null |
响应头包含:
X-Request-ID: <本次请求ID>
Cache-Control: no-store
错误响应统一为:
{
"code": 422,
"msg": "like_cnt筛选值必须是数字: invalid",
"data": null
}
| HTTP 状态 | 场景 | 调用方处理建议 |
|---|---|---|
| 400 | JSON 无效、字段类型错误、未知参数、时间范围错误 | 修正请求,不重试 |
| 401 | Token 缺失或错误 | 修正鉴权,不重试 |
| 422 | 结构化过滤值不符合业务要求 | 修正参数,不重试 |
| 500 | 数据库查询异常或内部异常 | 记录 X-Request-ID,排查日志;谨慎重试 |
| 503 | 查询并发已满,或 /ready 检查失败 |
指数退避后重试 |
| 504 | 数据库查询超过 API_QUERY_TIMEOUT |
缩小时间范围/关键词或稍后重试 |
调度端当前只自动重试网络异常以及 HTTP 429、502、503、504,最多 3 次;退避约为 1 秒、2 秒、4 秒并附加随机抖动。400、401、422 不会重试。
默认配置:
| 配置 | 默认值 | 说明 |
|---|---|---|
API_HOST |
127.0.0.1 |
只监听本机,由 Nginx 对外代理 |
API_PORT |
8888 |
API 本地监听端口 |
DB_POOL_SIZE |
12 |
单进程数据库连接池上限 |
DB_POOL_RECYCLE |
3600 |
数据库连接回收秒数 |
API_MAX_CONCURRENT_REQUESTS |
8 |
查询并发配置上限 |
API_QUEUE_TIMEOUT |
2.0 |
等待查询并发槽位的最长秒数 |
API_QUERY_TIMEOUT |
30 |
单条查询最长秒数 |
API_MAX_LIMIT |
500 |
单页最大返回条数 |
API_LOG_QUEUE_SIZE |
1000 |
SLS 异步日志队列长度 |
API_LOG_FLUSH_TIMEOUT |
5.0 |
单批 SLS 上报及关闭刷新超时 |
实际查询并发取以下两者最小值:
min(API_MAX_CONCURRENT_REQUESTS, DB_POOL_SIZE)
这样可以避免大量查询进入数据库连接池内部无界等待。超过并发槽位且 2 秒内未获得执行机会时返回 503。
如果使用多个 API 进程,总数据库连接数约为:
进程数 × DB_POOL_SIZE
扩容进程前必须确认数据库最大连接数和实际负载。
本地访问日志记录:
request_id不记录响应视频内容,避免日志量过大。
请求完成后只写入有界内存队列,由后台任务批量上报,业务响应不等待 SLS。
后台策略:
主要字段:
TraceId / request_idpath、methodstatus_code、successrequest_duration_msquery_duration_msquery_result_countquery_successfailure_stageerror_typemessagerequest_paramsAPI日志全部使用SLS顶层字段,不再写入重复的 data 字符串。字典和数组字段使用紧凑JSON字符串,
布尔值统一为小写 true/false;值为空的 failure_stage、error_type 等字段不会写入。
account=None 和自定义 timestamp 也不会出现在API日志中,时间以SLS自带的 __time__ 为准。
这样可以直接统计每日请求量、成功率、P95/P99耗时和错误分布。
API访问日志使用独立的SLS目的地,默认配置为:
API_ALIYUN_LOG_PROJECT=crawler-log-prod
API_ALIYUN_LOGSTORE=crawler-api-access
API_ALIYUN_LOG_ENDPOINT=cn-hangzhou.log.aliyuncs.com
部署前需要在阿里云日志服务中创建 crawler-api-access Logstore,并为上面的核心指标建立索引。
原有爬虫日志继续写入 crawler-fetch,两者不会混在一起。如果需要使用其他Project或Logstore,
只修改环境变量并重启API即可。
以下示例假设:
export CHUI_ZHI_API_URL="https://api.example.com/api/v1/crawler/videos/query"
curl --request POST "$CHUI_ZHI_API_URL" \
--header "Content-Type: application/json" \
--header "X-Request-ID: manual-test-001" \
--data '{}'
curl --request POST "$CHUI_ZHI_API_URL" \
--header "Content-Type: application/json" \
--data '{
"keywords": ["早"],
"limit": 500
}'
curl --request POST "$CHUI_ZHI_API_URL" \
--header "Content-Type: application/json" \
--data '{
"filter_match_mode": 2,
"filters": [
{"field": "create_time", "operator": ">=", "value": 1786896000000},
{"field": "create_time", "operator": "<", "value": 1786982400000},
{"field": "like_cnt", "operator": ">", "value": 3},
{"field": "duration", "operator": "between", "value": [60, 300]}
],
"limit": 200
}'
curl --request POST "$CHUI_ZHI_API_URL" \
--header "Content-Type: application/json" \
--data '{
"keywords": ["早"],
"limit": 500,
"cursor": {"id": 6815000, "query_time": 1786982400000}
}'
后续页必须继续使用第一页的关键词、过滤条件和 limit,并原样传递响应中的 next_cursor。
在 API 所在服务器执行:
curl --fail http://127.0.0.1:8888/health
curl --fail http://127.0.0.1:8888/ready
现有 deploy.sh 已同时管理主爬虫和垂直视频 API。在服务器的 /root/AutoScraperX/.env 中增加:
API_HOST=127.0.0.1
API_PORT=8888
API_ALIYUN_LOG_PROJECT=crawler-log-prod
API_ALIYUN_LOGSTORE=crawler-api-access
API_ALIYUN_LOG_ENDPOINT=cn-hangzhou.log.aliyuncs.com
执行完整部署:
cd /root/AutoScraperX
bash deploy.sh
部署脚本按以下顺序执行:
master 最新代码。/root/AutoScraperX/venv。requirements.txt。python -m api.app(Uvicorn单Worker)。/ready,确认进程和数据库都可用。main.py。部署产物:
| 文件 | 用途 |
|---|---|
logs/autoscraperx_deploy.log |
部署过程日志 |
logs/api_startup.log |
API 标准输出和启动异常 |
logs/main_startup.log |
主爬虫标准输出和启动异常 |
run/api.pid |
API 进程 PID |
run/main.pid |
主爬虫进程 PID |
部署完成后验证:
curl --fail http://127.0.0.1:8888/health
curl --fail http://127.0.0.1:8888/ready
cat /root/AutoScraperX/run/api.pid
cat /root/AutoScraperX/run/main.pid
tail -n 100 /root/AutoScraperX/logs/autoscraperx_deploy.log
如果服务器目录或虚拟环境路径不同,可以覆盖:
APP_DIR=/data/AutoScraperX VENV_DIR=/data/AutoScraperX/venv bash deploy.sh
cd /root/AutoScraperX
./run_api.sh prod
run_api.sh 优先使用 venv/bin/python,不存在时使用 .venv/bin/python。
默认监听:
127.0.0.1:8888
启动窗口会打印所有已注册的路径和处理方法。
示例文件:
deploy/nginx/chui_zhi_api.conf.example
部署要点:
127.0.0.1:8888X-Request-ID、真实 IP 和协议/health、/ready 只允许本机访问安装示例:
cp deploy/nginx/chui_zhi_api.conf.example /etc/nginx/conf.d/chui_zhi_api.conf
# 编辑域名、证书路径和可选IP白名单
nginx -t
systemctl reload nginx
deploy.sh 不会自动覆盖 Nginx 配置、域名和证书。Nginx 首次配置完成后,后续运行 deploy.sh 只需重启其后端 API。
示例文件:
deploy/systemd/autoscraperx-api.service.example
systemd 配置了进程异常自动重启、SIGTERM 优雅退出和文件句柄上限。它是 deploy.sh + nohup 的替代方案,同一 API 不要同时由两套方式管理。
调度端配置:
export CHUI_ZHI_API_URL="https://api.example.com/api/v1/crawler/videos/query"
调度流程:
has_more=true 时原样传递 next_cursor 请求下一页。has_more=false 时结束。调度端复用 requests.Session,保持 TCP 连接;每一页处理完成后才请求下一页,避免一次性把全部视频加载到内存。
调度端仍会在入库前检查该计划是否已经存在对应内容,保证任务重试时尽量避免重复上传和保存。
2026-08-18 使用当前实现实测:
早该结果只代表当时数据库数据量、网络和负载,不是固定 SLA。短关键词 %早% 命中范围较大,通常比长关键词更慢。
建议持续通过 SLS 的 query_duration_ms 观察:
如果查询长期接近 30 秒,应优先缩小时间范围或页大小,并结合生产数据执行 EXPLAIN。数据库结构优化需单独评估和审批,本接口不会自动执行建索引或字段变更。
| 功能 | 文件 |
|---|---|
| 稳定ASGI启动入口 | api/app.py |
| FastAPI应用、请求上下文、校验错误兜底和生命周期 | api/fastapi_app.py |
| API参数基类、注册、执行、响应、异常及数据库查询保护 | api/base.py |
| 本地访问日志、SLS上报、脱敏及日志队列 | api/reporting.py |
| 业务Router集中注册 | api/routes.py |
| API公共异常 | api/errors.py |
| 垂直视频入参、SQL、查询、分页及Router | api/chui_zhi/videos.py |
| API 和数据库配置 | config/base.py |
| 异步 MySQL 连接池 | core/base/async_mysql_client.py |
| 阿里云 SLS 批量日志 | core/utils/log/aliyun_log.py |
| 启动脚本 | run_api.sh |
| Nginx 示例 | deploy/nginx/chui_zhi_api.conf.example |
| systemd 示例 | deploy/systemd/autoscraperx-api.service.example |
| API 测试 | test/test_video_query_api.py |
每个接口模块只管理请求模型和业务实现。请求模型继承 ApiParams,接口继承 BaseApi;
子类只需声明必填的 path 并实现 logic,直接返回业务数据。请求方法默认是 POST,路由名称和说明自动生成。
路由注册、code/msg/data 响应、业务异常、请求ID、本地访问日志和阿里云SLS上报均由基类处理。
数据库列表查询统一使用基类的 fetch_all(),并发限制、查询超时、耗时和结果数量也会自动处理。
参数在进入子类前校验失败、404/405 和健康检查由全局中间件兜底,使用同一套响应和上报逻辑,且不会重复上报。
from fastapi import APIRouter, Request
from api.base import ApiParams, BaseApi
router = APIRouter(prefix='/api/v1/example', tags=['示例'])
class ExampleParams(ApiParams):
value: str
class ExampleApi(BaseApi):
"""执行示例功能。"""
path = '/run'
async def logic(self, params: ExampleParams, request: Request):
return {'value': params.value}
ExampleApi().register(router)
最后只需在 api/routes.py 中 include_router。子类不要捕获公共异常、不要重复封装
code/msg/data,也不要自行记录或上报访问日志。