我如何返回一个与它从网页抓取的数据相匹配的字符串数组?
我写了一个Letterboxd的网页爬虫,目标是抓取某一页上的所有评论。我在有头模式下确认它能成功点击每个“...more”标签以揭露页面上被压缩的评论,但在之后查看字符串表示时,离页面底部最近的展开标签总是以未展开的形式呈现。
这个评论的末尾不应该再有一个"...more":

我的实现中是不是出现了某种同步错误?我不知道该怎么做,才能确保在字符串转换时网页上的所有内容都被正确提取。我还附上了在点击它们前后的页面元素状态。
let revealsRemaining = await firstPage.locator('a.reveal').count();
while (revealsRemaining > 0) {
let revealLink = firstPage.locator('a.reveal').first();
await revealLink.click();
revealsRemaining--;
}
const pageOneReviews = await firstPage.locator('.js-listitem .js-review .body-text')
.evaluateAll( els =>
els.map(el => // This will already isolate each individual instance of the locator tag
{
const nodeOfReview = el.querySelectorAll("p"); // Unpacks every p tag within each review into a NodeList
let concatReview = "";
for (let i = 0; i < nodeOfReview.length; i++)
{
let paragraph = nodeOfReview.item(i).textContent; // Get the text content of each paragraph (every item is a DOM element
// that can be treated as a standard tag in HTML)
concatReview += paragraph; // Append onto concatReview
}
return concatReview; // Return the entire text content and then push it onto the reviews list
}
)
);
await context.close();
return { pageOneReviews };
展开前的评论正文状态:

展开后的评论正文状态:

解决方案
首先,重要的是把你正在爬取的实际站点分享出来,方便回答者复现问题并确保他们解决的是正确的问题。你截图中的文本经过检索得到了 https://letterboxd.com/itscharlibb/film/erupcja/2/,因此我猜那就是你正在爬取的页面*。
在网页抓取中的一个通用提示:不要想着像人类用户那样操作**。人类用户对编程一无所知,需要点击才能看到完整的评论。但你是程序员,可以直接查看页面源代码并提取信息。像在原始HTML响应中搜索隐藏文本,例如“去看看,它很特别。”,会发现数据在类名为 .js-review-body 的元素中是静态可用的。使用它来提取。
const playwright = require("playwright"); // ^1.58.0
let browser;
(async () => {
browser = await playwright.chromium.launch({headless: false});
const page = await browser.newPage();
const url = "https://letterboxd.com/itscharlibb/film/erupcja/2/";
await page.goto(url, {waitUntil: "domcontentloaded"});
console.log(await page.locator(".js-review-body").textContent());
})()
.catch(err => console.error(err))
.finally(() => browser?.close());
由于不需要JavaScript和额外的网络资源,你可以完全屏蔽它们,这不仅减轻服务器负担,还能加速你的脚本:
let browser;
let context;
(async () => {
browser = await playwright.chromium.launch({headless: false});
context = await browser.newContext({javaScriptEnabled: false});
const page = await context.newPage();
const url = "https://letterboxd.com/itscharlibb/film/erupcja/2/";
await page.route("**", route => {
if (route.request().url().startsWith(url)) {
route.continue();
}
else {
route.abort();
}
});
await page.goto(url, {waitUntil: "domcontentloaded"});
console.log(await page.locator(".js-review-body").textContent());
})()
.catch(err => console.error(err))
.finally(async () => {
await context?.close();
await browser?.close();
});
请注意,上述方法使用的是带界面的Playwright。这一点很重要,因为该站点有反机器人保护,因此会拒绝无头浏览器和纯获取请求。
有多种绕过方法,例如使用真实的Chrome浏览器,或者像cuimp-ts这样的浏览器伪装工具:
import * as cheerio from "cheerio"; // ^1.2.0
import {get} from "cuimp"; // ^1.10.0
const url = "https://letterboxd.com/itscharlibb/film/erupcja/2/";
const response = await get(url);
console.log(cheerio.load(response.data)(".js-review-body").text());
上述方法是完全无头的,且比带界面的浏览器更轻量。这在通过API或静态HTML直接找到数据后是一种常见的重构。
*: 在写完这个回答后,我意识到你其实很可能在抓取列表页 https://letterboxd.com/film/erupcja/。
将上述cuimp方案扩展以抓取所有评论也很直接。在评论列表页,提取每条评论详情的链接并逐个请求:
const url = "https://letterboxd.com/film/erupcja/";
const response = await get(url);
const $ = cheerio.load(response.data);
const reviews = [];
for (const el of [...$("[title^='Read']")]) {
const base = "https://letterboxd.com/";
const response = await get(base + $(el).attr("href"));
reviews.push(cheerio.load(response.data)(".js-review-body").text().trim());
}
console.log(reviews);
或者如果你在抓取 https://letterboxd.com/film/erupcja/reviews/by/activity/,那么需要使用不同的选择器并遍历分页:
const url = "https://letterboxd.com/film/erupcja/reviews/by/activity/page/";
const reviews = [];
const maxPages = 5; // increase to 500 for a full run
for (let page = 1; page < maxPages; page++) {
const response = await get(url + page);
const $ = cheerio.load(response.data);
const reviewLinks = [...$(".attribution-detail a")];
if (reviewLinks.length === 0) {
break;
}
for (const el of reviewLinks) {
const base = "https://letterboxd.com/";
const response = await get(base + $(el).attr("href"));
reviews.push(cheerio.load(response.data)(".js-review-body").text().trim());
}
}
console.log(reviews);
console.log(reviews.length);
同时边抓边把评论写入文件也是一个好主意,因为这是一次相当大规模的爬取(约250页,每页12条评论,总请求数超过3k次)。
你可以用队列来加速(pLimit是一个不错的库),但这已经非常快了,所以要么就保持现状,要么通过在请求之间加入一些睡眠来降低速度,并接受运行时间会比较久的现实。
如果你确实想坚持使用Playwright,请记住,点击“more”按钮并不是同步渲染。这会触发一个服务器请求,与Playwright读取文本的过程并发进行,因此你需要等待网络响应,或等待文本变化。
这有点麻烦,也很容易变得不稳定。除非在cuimp上遇到真正的阻塞,且增加睡眠/限流来尊重服务器的方法都无效时,我才会考虑使用Playwright。
*: 注意,如果你是在用Playwright测试你自己的应用,而不是在爬取数据,那么应遵循相反的原则:尽量让你的用户的行为像真实用户一样,这样你就能更有信心地确认你的应用按预期工作。