Compare commits
2
Commits
a1a93cc8e0
...
3dc5f02aaf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dc5f02aaf | ||
|
|
21c11fb04a |
@@ -0,0 +1,90 @@
|
||||
name: Deploy
|
||||
|
||||
# push 到 dev 分支 → 在 dev 开发机上重新 build 镜像、重建 content-harvester 容器。
|
||||
# (只管 dev;prod 仍手动部署 —— 见 DEPLOY.md。)
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Build and deploy (dev)
|
||||
# dev 开发机的 gitea runner 标签(与主站 AIautomation 的 deploy.yml 一致)
|
||||
runs-on: srv1563674
|
||||
|
||||
env:
|
||||
IMAGE: content-harvester
|
||||
CONTAINER: content-harvester
|
||||
HOST_PORT: "8019" # 主机端口(主要给本机调试用)
|
||||
DOCKER_NETWORK: ai-admin-cuiyue_default # 跟主站 backend 同一个 docker 网络
|
||||
PROFILE_VOLUME: content-harvester-profile # 持久浏览器资料目录(过 Cloudflare 用)
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Prepare env file
|
||||
env:
|
||||
CH_ENV_FILE: ${{ secrets.CH_ENV_FILE }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${CH_ENV_FILE:-}" ]; then
|
||||
printf '%s\n' "$CH_ENV_FILE" > .env
|
||||
fi
|
||||
test -f .env || {
|
||||
echo "Missing .env — 请在该仓库的 Gitea Actions secrets 里加一个 'CH_ENV_FILE',内容形如:"
|
||||
echo " HARVESTER_TOKEN=..."
|
||||
echo " MINIO_ENDPOINT=https://minio.aidfxn.com"
|
||||
echo " MINIO_BUCKET=clawlerdata"
|
||||
echo " MINIO_ACCESS_KEY=..."
|
||||
echo " MINIO_SECRET_KEY=..."
|
||||
exit 1
|
||||
}
|
||||
# 只打 key 名,不打值
|
||||
echo "env keys: $(grep -oE '^[A-Za-z_][A-Za-z0-9_]*' .env | paste -sd, -)"
|
||||
|
||||
- name: Show docker info
|
||||
run: docker --version
|
||||
|
||||
- name: Build image
|
||||
run: docker build -t "$IMAGE:latest" .
|
||||
|
||||
- name: Recreate container
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
||||
docker run -d \
|
||||
--name "$CONTAINER" \
|
||||
--restart unless-stopped \
|
||||
--network "$DOCKER_NETWORK" \
|
||||
-p "${HOST_PORT}:8000" \
|
||||
--env-file .env \
|
||||
-v "${PROFILE_VOLUME}:/data/browser_profile" \
|
||||
--memory=2g --cpus=2 \
|
||||
"$IMAGE:latest"
|
||||
|
||||
- name: Health check
|
||||
run: |
|
||||
set -e
|
||||
for i in $(seq 1 20); do
|
||||
if docker exec "$CONTAINER" python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3).read()" >/dev/null 2>&1; then
|
||||
echo "healthz ok"
|
||||
docker ps --filter "name=$CONTAINER" --format '{{.Names}} | {{.Status}} | {{.Ports}}'
|
||||
exit 0
|
||||
fi
|
||||
echo "waiting for healthz... ($i/20)"
|
||||
sleep 3
|
||||
done
|
||||
echo "health check failed"
|
||||
docker logs --tail 80 "$CONTAINER" || true
|
||||
exit 1
|
||||
|
||||
- name: Cleanup env file
|
||||
if: always()
|
||||
run: rm -f .env
|
||||
|
||||
- name: Prune dangling images
|
||||
if: always()
|
||||
run: docker image prune -f >/dev/null 2>&1 || true
|
||||
@@ -8,27 +8,46 @@
|
||||
- 代码:`git clone -b dev https://git.aidfxn.com/admin/content-harvester.git`,以后 `git pull origin dev`。
|
||||
- 对接的主站 Skill:「内容网站采集(content-harvester)」(`executor_type=lobster`,`lobster_api_url=http://content-harvester:8000`,`lobster_api_key` = `.env.dev` 里的 `HARVESTER_TOKEN`)。
|
||||
|
||||
## 重新部署 / 更新(改了代码、push 了 dev 之后)
|
||||
## 重新部署 / 更新
|
||||
|
||||
本仓库**没有自动部署 workflow**,要手动:
|
||||
### dev:自动部署(push `dev` 分支即触发)
|
||||
|
||||
`.gitea/workflows/deploy.yml` —— **push 到 `dev` 分支**(或在 gitea 上手动 `workflow_dispatch`)→ dev 开发机的 gitea runner(标签 `srv1563674`,跟主站 AIautomation 的 deploy.yml 用同一个)自动:`actions/checkout` → `docker build -t content-harvester .` → `docker rm -f content-harvester` → `docker run -d ...`(env 走 `--env-file .env`)→ healthz 检查 → `docker image prune`。**只管 dev**;`master`/prod 不在 `on.push.branches` 里,不会被这个工作流碰(prod 仍走下面的手动流程)。
|
||||
|
||||
**前置条件(一次性)**:在该仓库的 Gitea Actions secrets 里加一个 **`CH_ENV_FILE`**,内容就是要喂给容器的 env 文件全文(等价于 `.env.dev`),至少:
|
||||
|
||||
```
|
||||
HARVESTER_TOKEN=<和主站采集 Skill 的 lobster_api_key 一致>
|
||||
MINIO_ENDPOINT=https://minio.aidfxn.com
|
||||
MINIO_BUCKET=clawlerdata
|
||||
MINIO_ACCESS_KEY=<...>
|
||||
MINIO_SECRET_KEY=<...>
|
||||
# 可选:HEADLESS=0 / BROWSER_CHANNEL=chrome / MAX_PAGES=... / DEFAULT_MAX_IMAGES=... 等
|
||||
```
|
||||
|
||||
没配 `CH_ENV_FILE` 的话工作流会在「Prepare env file」那步报错退出(不会拿一份残缺的 env 去跑)。工作流里硬编码的部署参数(非密钥):host 端口 `8019`、docker 网络 `ai-admin-cuiyue_default`、卷 `content-harvester-profile:/data/browser_profile`、`--memory=2g --cpus=2`、`--restart unless-stopped` —— 要改这些就改 `deploy.yml` 顶部的 `env:` 块。
|
||||
|
||||
> 工作流在 runner 的 workspace 里 build(`actions/checkout` 取的那份代码),**不动 `/opt/content-harvester`** —— 那个目录会逐渐过时,「现在 dev 跑的是哪份代码」要看运行中的容器(`docker exec content-harvester ...`),别看 `/opt/content-harvester` 的 git。
|
||||
|
||||
### 手动部署(prod;或 dev 想临时绕过工作流)
|
||||
|
||||
```bash
|
||||
cd /opt/content-harvester
|
||||
git pull -q origin dev
|
||||
git pull -q origin <dev|master> # prod 用 master
|
||||
docker build -t content-harvester .
|
||||
docker rm -f content-harvester
|
||||
docker run -d --name content-harvester \
|
||||
--restart unless-stopped \
|
||||
--network <主站 backend 所在的 docker 网络> \
|
||||
--network ai-admin-cuiyue_default \ # 跟主站 backend 同一个 docker 网络
|
||||
-p 8019:8000 \
|
||||
--env-file /opt/content-harvester/.env.dev \ # 这个文件要先放到服务器上(本地 scp 过去,或在服务器上照样写一份)
|
||||
--env-file /opt/content-harvester/.env.dev \ # 这个文件要先放到服务器上(本地 scp 过去,或在服务器上照样写一份;内容同 CH_ENV_FILE)
|
||||
-v content-harvester-profile:/data/browser_profile \
|
||||
--memory=2g --cpus=2 \
|
||||
content-harvester
|
||||
sleep 6 && curl -s localhost:8019/healthz # 期望 {"ok":true,"headless":false,...}
|
||||
```
|
||||
|
||||
> 注:`.env.dev` 被 `.gitignore` 排除,`git pull` 不会带它过去。第一次在服务器上部署时手动把它放到 `/opt/content-harvester/.env.dev`(从本地 scp,或照本地内容写一份)。或者也可以不用 env-file、把那几个 `-e KEY=VALUE` 直接展开写在 `docker run` 里(但 token/凭据就会出现在 shell history / `docker inspect`)。
|
||||
> 注:`.env.dev` 被 `.gitignore` 排除,`git pull` 不会带它过去。第一次在服务器上手动部署时把它放到 `/opt/content-harvester/.env.dev`(从本地 scp,或照本地内容写一份)。或者也可以不用 env-file、把那几个 `-e KEY=VALUE` 直接展开写在 `docker run` 里(但 token/凭据就会出现在 shell history / `docker inspect`)。
|
||||
|
||||
如果一时没法在服务器上 build,也可以本地 `docker build` 后 `docker save content-harvester | gzip | ssh ... 'gunzip | docker load'` —— 但**注意 CPU 架构**:Mac 一般是 arm64,服务器多是 x86_64,本地构建的 arm64 镜像在服务器跑不了。所以一般还是在服务器上 build。
|
||||
|
||||
|
||||
@@ -201,16 +201,8 @@ curl -s localhost:8019/healthz # {"ok":true,"headless":false,...}
|
||||
|
||||
### 更新
|
||||
|
||||
本仓库**没有 gitea 自动部署 workflow**,要手动:
|
||||
|
||||
```bash
|
||||
cd <部署目录> && git pull origin dev
|
||||
docker build -t content-harvester .
|
||||
docker rm -f content-harvester
|
||||
docker run -d ... content-harvester # 同上面那串参数
|
||||
```
|
||||
|
||||
> 各环境的具体服务器、目录、容器、token / 凭据见 `DEPLOY.md` 与本地运维记录(不入库)。
|
||||
- **dev**:有自动部署 —— `.gitea/workflows/deploy.yml`,**push 到 `dev` 分支**(或 gitea 上 `workflow_dispatch`)→ dev 开发机的 runner 自动 `docker build` + 重建 `content-harvester` 容器 + healthz 检查。前置:在该仓库的 Gitea Actions secrets 里配一个 `CH_ENV_FILE`(等价于 `.env.dev` 全文,`HARVESTER_TOKEN` + 4 个 `MINIO_*` + 可选项)。
|
||||
- **prod / 手动**:`master`/prod 不走那个工作流,仍手动:`cd <部署目录> && git pull origin <dev|master> && docker build -t content-harvester . && docker rm -f content-harvester && docker run -d ... content-harvester`(完整命令、各环境的服务器/目录/容器/凭据见 `DEPLOY.md` 与本地运维记录,不入库)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+32
-32
@@ -464,46 +464,46 @@ class XChinaScraper:
|
||||
# ---- description / content -------------------------------------------
|
||||
|
||||
async def _extract_description(self, page, title: str) -> tuple[str | None, str]:
|
||||
"""返回 (summary, content)。content 保证非空(实在没有就用 title)——
|
||||
主站资源库 ingest 对空 content 直接拒收。"""
|
||||
"""返回 (summary, content)。content 保证非空(实在没有就用 title)——主站资源库 ingest 对空 content 直接拒收。
|
||||
|
||||
xchina 视频详情页**没有真正的简介文本**:`.video-detail` 面板就是 标题 / 模特 / 分类 / 系列名,
|
||||
再加一整块「推广:...」广告位(里面是 `a.cpc-text` → xchina.click/prepare.html 的联盟跳转,常含
|
||||
VPN/楼凤/约炮乃至「幼女」「学生妹」这类违规词,会被主站内容审核 block)。`meta description` 也只是
|
||||
站点级 boilerplate。所以这里只抓干净的结构化字段:模特名 + 分类/系列名(限同源 xchina.co,排除 cpc-text /
|
||||
xchina.click / prepare.html),拼成 content;不要面板 innerText、不要 boilerplate meta/jsonld。
|
||||
"""
|
||||
data = await page.evaluate(
|
||||
"""() => {
|
||||
function clean(t){ return (t || '').replace(/\\s+/g,' ').trim(); }
|
||||
const out = {};
|
||||
const m = document.querySelector('meta[name="description"], meta[property="og:description"], meta[name="twitter:description"]');
|
||||
out.meta_desc = clean(m && m.getAttribute('content'));
|
||||
for (const s of document.querySelectorAll('script[type="application/ld+json"]')) {
|
||||
try { const d = JSON.parse(s.textContent||'null'); const rows = Array.isArray(d)?d:[d];
|
||||
for (const r of rows) { if (r && r.description) { out.jsonld_desc = clean(r.description); break; } } } catch(_){}
|
||||
const out = {model: '', cats: []};
|
||||
const ma = document.querySelector('.video-detail a[href*="/model/id-"], a.model-item, .model-container a[href*="/model/id-"]');
|
||||
out.model = clean(ma && ma.innerText);
|
||||
const seen = new Set();
|
||||
for (const a of document.querySelectorAll('.video-detail a, .video-info a, .breadcrumb a')) {
|
||||
if (a.classList && a.classList.contains('cpc-text')) continue; // 广告位链接
|
||||
let h; try { h = new URL(a.href, location.href); } catch(e){ continue; }
|
||||
if (!h.host.endsWith('xchina.co')) continue; // 排除 xchina.click 等广告域
|
||||
if (h.pathname.indexOf('prepare.html') >= 0) continue;
|
||||
const ok = h.pathname.indexOf('/videos/series-') >= 0 || h.pathname.indexOf('/photos/series-') >= 0
|
||||
|| h.pathname.indexOf('/tags/') === 0 || h.pathname.indexOf('/tag/') === 0;
|
||||
if (!ok) continue;
|
||||
const t = clean(a.innerText);
|
||||
if (t && t.length <= 24 && !seen.has(t)) { seen.add(t); out.cats.push(t); }
|
||||
}
|
||||
// 影片信息 / 详情面板里的文本(键值对、简介等)
|
||||
const panel = document.querySelector('.video-detail') || document.querySelector('.tab-content.active') || document.querySelector('.tab-content');
|
||||
out.panel = panel ? clean(panel.innerText).slice(0, 4000) : '';
|
||||
// 标签
|
||||
const tags = [];
|
||||
for (const a of document.querySelectorAll('.tags a, .tag a, a[href*="/tags/"], .video-detail .item a')) {
|
||||
const t = clean(a.innerText); if (t && t.length<=20 && !tags.includes(t)) tags.push(t);
|
||||
}
|
||||
out.tags = tags.slice(0, 30);
|
||||
out.cats = out.cats.slice(0, 8);
|
||||
return out;
|
||||
}"""
|
||||
)
|
||||
meta_desc = (data.get("meta_desc") or "").strip()
|
||||
jsonld_desc = (data.get("jsonld_desc") or "").strip()
|
||||
panel = (data.get("panel") or "").strip()
|
||||
tags = data.get("tags") or []
|
||||
# content: 优先详情面板文本(信息量最大),否则 og/jsonld 描述,再否则 标签拼接,最后 title
|
||||
model = (data.get("model") or "").strip()
|
||||
cats = data.get("cats") or []
|
||||
parts: list[str] = []
|
||||
body = panel or jsonld_desc or meta_desc
|
||||
if body:
|
||||
parts.append(body)
|
||||
if tags:
|
||||
parts.append("标签: " + " ".join(tags))
|
||||
content = "\n".join(parts).strip()
|
||||
if not content:
|
||||
content = title
|
||||
# summary: og/jsonld 描述,或 content 头一截
|
||||
summary = (meta_desc or jsonld_desc or content)[:300].strip() or None
|
||||
if model:
|
||||
parts.append(f"模特: {model}")
|
||||
if cats:
|
||||
parts.append("分类: " + " / ".join(cats))
|
||||
content = ("\n".join([title, " · ".join(parts)]).strip() if parts else (title or "").strip()) or (title or "")
|
||||
bits = [b for b in [model, *cats[:3]] if b]
|
||||
summary = " · ".join(bits) if bits else None
|
||||
return summary, content
|
||||
|
||||
# ---- cover -----------------------------------------------------------
|
||||
|
||||
@@ -365,7 +365,10 @@ class XChinaPhotoScraper(XChinaScraper):
|
||||
|| document.querySelector('a[href*="/photos/series-"]');
|
||||
out.series = clean(sa && sa.innerText);
|
||||
const tags = [];
|
||||
for (const a of document.querySelectorAll('a[href*="/tag"], .tags a, .tag a')) {
|
||||
for (const a of document.querySelectorAll('a[href*="/tags/"], .tags a, .tag a')) {
|
||||
if (a.classList && a.classList.contains('cpc-text')) continue; // 广告位链接
|
||||
let h; try { h = new URL(a.href, location.href); } catch(e){ continue; }
|
||||
if (!h.host.endsWith('xchina.co') || h.pathname.indexOf('prepare.html') >= 0) continue;
|
||||
const t = clean(a.innerText);
|
||||
if (t && t.length <= 20 && tags.indexOf(t) < 0) tags.push(t);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user