用户发来 https://mp.weixin.qq.com/s/ZEOsi4R4JcmZ_mhS6qpcww,存进库的正文只有 67 个字符 ("原文链接 + 一个句号")。复盘发现公众号换了渲染方式:新版页面把整篇塞进 window.cgiDataNew 这个 JS 对象由前端渲染,页面里根本没有 id="js_content"; 三种 UA 拿到的字节数完全一样,也不是 UA 的问题。 - 从 JS 字符串字面量里读正文(content_noencode / desc),按 \xNN、\uNNNN 反转义, 话题标签是双重转义过的 HTML,先 unescape 再走 DOM。 - 配图字段排在文字之后,所以前后各找 2500 字符合图;剩下没用过的 mmbiz 图按 12 张上限补在末尾(图文型文章靠就近配对会漏)。 - 解析顺序:旧 DOM → 新版 JS 数据 → 通用抽取,一层层退。 实测两篇:2373 字符 + 12 张图、2772 字符 + 13 张图,作者也都取到了。 同时补上用户提的两件: - 知识能删了:DELETE /knowledge/{id}?confirm=true,软删 + 真删检索切片, 只有作者或 owner/admin 能删;列表卡片(悬停出现)和阅读页动作行各一个入口。 删完详情也回 7001,否则收藏的链接还能打开一篇已删除的知识。 - 分类下面有标签清单了:GET /knowledge/tags 按标签数"有多少条知识用了它", 侧栏渲染成 #标签 + 条数,点一下筛选、再点取消。
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 知识库的两件小事:分类下面的标签清单能点着筛,文章能删。
|
||||
*
|
||||
* 为什么单独写一条:标签统计和删除都是"后端好了但界面没入口"的典型,
|
||||
* 看不见就等于没有;所以这里从界面点,最后用接口回查确认真的生效。
|
||||
*
|
||||
* 用法:
|
||||
* node web/e2e/knowledge-tags-delete.mjs # 默认 dev 5180
|
||||
* WEB=http://192.168.8.188:8200 PHONE=13605513683 PASSWORD=... node web/e2e/knowledge-tags-delete.mjs
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { chromium } from "playwright-core";
|
||||
|
||||
const WEB = process.env.WEB ?? "http://localhost:5180";
|
||||
const API = process.env.API ?? `${WEB}/api/v1`;
|
||||
const PHONE = process.env.PHONE ?? "13900000001";
|
||||
const CHROME =
|
||||
process.env.CHROME ??
|
||||
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`;
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
function check(name, ok, extra = "") {
|
||||
if (ok) {
|
||||
pass += 1;
|
||||
console.log(`PASS ${name}`);
|
||||
} else {
|
||||
fail += 1;
|
||||
console.log(`FAIL ${name} ${extra}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function readPassword() {
|
||||
if (process.env.PASSWORD) return process.env.PASSWORD;
|
||||
try {
|
||||
const file = new URL("../../.local/e2e-account-password.txt", import.meta.url);
|
||||
const text = (await readFile(file, "utf8")).trim();
|
||||
return text || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function call(path, { method = "GET", body, token } = {}) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const r = await fetch(`${API}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const password = await readPassword();
|
||||
if (!password) throw new Error("没有密码:配 PASSWORD 或写 .local/e2e-account-password.txt");
|
||||
const login = await call("/auth/login/password", {
|
||||
method: "POST",
|
||||
body: { phone: PHONE, password },
|
||||
});
|
||||
if (login.code !== 0) throw new Error(`登录失败:${login.message}`);
|
||||
const token = login.data.tokens.access_token;
|
||||
const spaces = (await call("/workspaces", { token })).data?.items ?? [];
|
||||
if (!spaces.length) throw new Error("这个账号没有工作区");
|
||||
const ws = spaces[0].id;
|
||||
|
||||
// 自己造数据:标签名带时间戳,避免和库里已有的同名标签混在一起数不准
|
||||
const stamp = Date.now().toString().slice(-6);
|
||||
const tag = `核对${stamp}`;
|
||||
const created = await call(`/workspaces/${ws}/knowledge`, {
|
||||
method: "POST",
|
||||
token,
|
||||
body: { title: `临时核对:标签与删除 ${stamp}`, body_md: "这条是 e2e 建的,跑完会删掉。", tag_names: [tag] },
|
||||
});
|
||||
if (created.code !== 0) throw new Error(`建知识失败:${created.message}`);
|
||||
const doc = created.data;
|
||||
await call(`/workspaces/${ws}/knowledge/${doc.id}/status`, {
|
||||
method: "POST",
|
||||
token,
|
||||
body: { action: "publish", version: doc.version },
|
||||
});
|
||||
|
||||
const browser = await chromium.launch({ executablePath: CHROME });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
await context.addInitScript(
|
||||
([access, refresh]) => {
|
||||
localStorage.setItem("dadao.access", access);
|
||||
localStorage.setItem("dadao.refresh", refresh);
|
||||
},
|
||||
[token, login.data.tokens.refresh_token],
|
||||
);
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on("pageerror", (e) => errors.push(String(e)));
|
||||
page.on("dialog", (d) => void d.accept());
|
||||
|
||||
await page.goto(`${WEB}/w/${ws}/knowledge`, { waitUntil: "networkidle" });
|
||||
|
||||
// ① 分类下面有「标签」块,且自己建的那个标签在里面
|
||||
const tagBox = page.locator("section", { hasText: "标签" }).last();
|
||||
const tagBtn = page.getByRole("button", { name: new RegExp(`#${tag}`) }).first();
|
||||
await tagBtn.waitFor({ timeout: 10000 });
|
||||
check("分类下面是标签清单", (await tagBox.count()) > 0);
|
||||
check("刚建的标签出现在侧栏(带条数)", (await tagBtn.count()) > 0);
|
||||
|
||||
// ② 点标签 → 列表只剩这一类
|
||||
await tagBtn.click();
|
||||
await page.waitForTimeout(800);
|
||||
const titles = await page.locator("li p.truncate").allInnerTexts();
|
||||
check(
|
||||
"按标签筛出的正好是自己那条",
|
||||
titles.filter((t) => t.includes(stamp)).length === 1 && titles.length >= 1,
|
||||
`titles=${titles.length}`,
|
||||
);
|
||||
check("标签被选中", (await page.getByRole("button", { name: new RegExp(`#${tag}`) }).first().getAttribute("aria-pressed")) === "true");
|
||||
|
||||
// ③ 再点一次取消筛选
|
||||
await tagBtn.click();
|
||||
await page.waitForTimeout(600);
|
||||
check(
|
||||
"再点一次取消标签筛选",
|
||||
(await page.getByRole("button", { name: new RegExp(`#${tag}`) }).first().getAttribute("aria-pressed")) === "false",
|
||||
);
|
||||
|
||||
// ④ 卡片上的删除:确认框点"确定",这条从列表消失
|
||||
const row = page.locator("li").filter({ hasText: stamp }).first();
|
||||
await row.hover();
|
||||
await page.getByRole("button", { name: `删除 临时核对:标签与删除 ${stamp}` }).first().click();
|
||||
await page.waitForTimeout(1200);
|
||||
check("列表里已经看不到这条", (await page.locator("li").filter({ hasText: stamp }).count()) === 0);
|
||||
|
||||
const afterDelete = await call(`/workspaces/${ws}/knowledge/${doc.id}`, { token });
|
||||
check("接口回查:这条确实没了", afterDelete.code !== 0, `code=${afterDelete.code}`);
|
||||
|
||||
// ⑤ 阅读页也有删除入口(换一条已存在的打开看看)
|
||||
const page2 = await call(`/workspaces/${ws}/knowledge?limit=1`, { token });
|
||||
const anyDoc = page2.data?.items?.[0];
|
||||
if (anyDoc) {
|
||||
await page.goto(`${WEB}/w/${ws}/knowledge/${anyDoc.id}`, { waitUntil: "networkidle" });
|
||||
check("阅读页有「删除」按钮", (await page.getByRole("button", { name: "删除" }).count()) > 0);
|
||||
}
|
||||
|
||||
check("控制台零报错", errors.length === 0, errors.slice(0, 2).join(" | "));
|
||||
|
||||
await page.goto(`${WEB}/w/${ws}/knowledge`, { waitUntil: "networkidle" });
|
||||
await page.screenshot({ path: "e2e/artifacts/knowledge-tags.png" });
|
||||
await browser.close();
|
||||
|
||||
// 收尾:万一界面那步没删掉,这里补一刀,别给下次跑留垃圾
|
||||
await call(`/workspaces/${ws}/knowledge/${doc.id}?confirm=true`, { method: "DELETE", token });
|
||||
|
||||
console.log(`\n${fail === 0 ? "ALL PASS" : "FAILED"} — pass ${pass}, fail ${fail}`);
|
||||
if (fail > 0) process.exitCode = 1;
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("FAIL 脚本异常 —", err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user