9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
142 lines
6.3 KiB
JavaScript
142 lines
6.3 KiB
JavaScript
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
import { buildTiebaReadItems } from './utils.js';
|
|
function getThreadUrl(kwargs) {
|
|
const threadId = String(kwargs.id || '');
|
|
const pageNumber = Math.max(1, Number(kwargs.page || 1));
|
|
return `https://tieba.baidu.com/p/${encodeURIComponent(threadId)}?pn=${pageNumber}`;
|
|
}
|
|
/**
|
|
* Ensure the browser actually landed on the requested thread page before we trust the DOM.
|
|
*/
|
|
function assertTiebaReadTargetPage(raw, kwargs) {
|
|
const expectedThreadId = String(kwargs.id || '').trim();
|
|
const expectedPageNumber = Math.max(1, Number(kwargs.page || 1));
|
|
const pathname = String(raw.pageMeta?.pathname || '').trim();
|
|
const actualThreadId = pathname.match(/^\/p\/(\d+)/)?.[1] || '';
|
|
const actualPn = String(raw.pageMeta?.pn || '').trim();
|
|
if (!actualThreadId || actualThreadId !== expectedThreadId) {
|
|
throw new EmptyResultError('tieba read', 'Tieba did not land on the requested thread page');
|
|
}
|
|
if (expectedPageNumber > 1 && actualPn !== String(expectedPageNumber)) {
|
|
throw new EmptyResultError('tieba read', 'Tieba did not land on the requested page');
|
|
}
|
|
}
|
|
function buildExtractReadEvaluate() {
|
|
return `
|
|
(async () => {
|
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
const waitFor = async (predicate, timeoutMs = 4000) => {
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs) {
|
|
if (predicate()) return true;
|
|
await wait(100);
|
|
}
|
|
return false;
|
|
};
|
|
const normalizeText = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
|
const getVueProps = (element) => {
|
|
const vue = element && element.__vue__ ? element.__vue__ : null;
|
|
return vue ? (vue._props || vue.$props || {}) : {};
|
|
};
|
|
const extractStructuredText = (content) => {
|
|
if (!Array.isArray(content)) return '';
|
|
return content
|
|
.map((part) => (part && typeof part === 'object' && typeof part.text === 'string') ? part.text : '')
|
|
.join('')
|
|
.replace(/\\s+/g, ' ')
|
|
.trim();
|
|
};
|
|
const parseFloor = (text) => {
|
|
const match = (text || '').match(/第(\\d+)楼/);
|
|
return match ? parseInt(match[1], 10) : 0;
|
|
};
|
|
|
|
await waitFor(() => {
|
|
const hasMainTree = document.querySelector('.pb-title-wrap.pc-pb-title') || document.querySelector('.pb-content-wrap');
|
|
return Boolean(hasMainTree || document.querySelector('.pb-comment-item'));
|
|
});
|
|
|
|
const titleNode = document.querySelector('.pb-title-wrap.pc-pb-title');
|
|
const titleProps = getVueProps(titleNode);
|
|
const mainUser = document.querySelector('.head-line.user-info:not(.no-extra-margin)');
|
|
const mainUserProps = getVueProps(mainUser);
|
|
const contentWrap = document.querySelector('.pb-content-wrap');
|
|
const contentProps = getVueProps(contentWrap);
|
|
const structuredContent = Array.isArray(contentProps.content) ? contentProps.content : [];
|
|
const visibleContent = normalizeText(
|
|
contentWrap?.querySelector('.pb-content-item .text')?.textContent
|
|
|| contentWrap?.querySelector('.text')?.textContent
|
|
|| contentWrap?.textContent
|
|
);
|
|
|
|
return {
|
|
pageMeta: {
|
|
pathname: window.location.pathname || '',
|
|
pn: new URLSearchParams(window.location.search).get('pn') || '',
|
|
},
|
|
mainPost: {
|
|
title: typeof titleProps.title === 'string' && titleProps.title.trim()
|
|
? titleProps.title.trim()
|
|
: normalizeText(titleNode?.textContent).replace(/-百度贴吧$/, '').trim(),
|
|
author: normalizeText(
|
|
mainUser?.querySelector('.head-name')?.textContent
|
|
|| mainUser?.querySelector('.name-info .head-name')?.textContent
|
|
|| ''
|
|
),
|
|
fallbackAuthor: mainUserProps?.userShowInfo?.[0]?.text?.text || '',
|
|
contentText: visibleContent,
|
|
structuredText: extractStructuredText(structuredContent),
|
|
visibleTime: (() => {
|
|
const userText = normalizeText(mainUser?.textContent);
|
|
const match = userText.match(/(刚刚|昨天|前天|\\d+\\s*(?:分钟|小时|天)前|\\d{2}-\\d{2}(?:\\s+\\d{2}:\\d{2})?|\\d{4}-\\d{2}-\\d{2}(?:\\s+\\d{2}:\\d{2})?)/);
|
|
return match ? match[1].trim() : '';
|
|
})(),
|
|
structuredTime: mainUserProps?.descInfo?.time || 0,
|
|
hasMedia: structuredContent.length > 0 && !extractStructuredText(structuredContent),
|
|
},
|
|
replies: Array.from(document.querySelectorAll('.pb-comment-item')).map((item) => {
|
|
const meta = item.querySelector('.comment-desc-left')?.textContent?.replace(/\\s+/g, ' ').trim() || '';
|
|
return {
|
|
floor: parseFloor(meta),
|
|
author: item.querySelector('.head-name')?.textContent?.trim() || '',
|
|
content: item.querySelector('.comment-content .pb-content-item .text')?.textContent?.replace(/\\s+/g, ' ').trim() || '',
|
|
time: meta,
|
|
};
|
|
}),
|
|
};
|
|
})()
|
|
`;
|
|
}
|
|
cli({
|
|
site: 'tieba',
|
|
name: 'read',
|
|
access: 'read',
|
|
description: 'Read a tieba thread',
|
|
domain: 'tieba.baidu.com',
|
|
strategy: Strategy.COOKIE,
|
|
browser: true,
|
|
navigateBefore: false,
|
|
args: [
|
|
{ name: 'id', positional: true, required: true, type: 'string', help: 'Thread ID' },
|
|
{ name: 'page', type: 'int', default: 1, help: 'Page number' },
|
|
{ name: 'limit', type: 'int', default: 30, help: 'Number of replies to return' },
|
|
],
|
|
columns: ['floor', 'author', 'content', 'time'],
|
|
func: async (page, kwargs) => {
|
|
const pageNumber = Math.max(1, Number(kwargs.page || 1));
|
|
// Use the browser's normal settle path so we do not scrape stale DOM from the previous tab state.
|
|
await page.goto(getThreadUrl(kwargs));
|
|
const raw = (await page.evaluate(buildExtractReadEvaluate()) || {});
|
|
assertTiebaReadTargetPage(raw, kwargs);
|
|
const items = buildTiebaReadItems(raw, {
|
|
limit: kwargs.limit,
|
|
includeMainPost: pageNumber === 1,
|
|
});
|
|
if (!items.length) {
|
|
throw new EmptyResultError('tieba read', 'Tieba may have blocked the thread page, or the DOM structure may have changed');
|
|
}
|
|
return items;
|
|
},
|
|
});
|