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
320 lines
10 KiB
JavaScript
320 lines
10 KiB
JavaScript
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||
export function stripHtml(html) {
|
||
if (!html)
|
||
return '';
|
||
const decoded = html
|
||
.replace(/\\u003c/g, '<')
|
||
.replace(/\\u003e/g, '>')
|
||
.replace(/\\n/g, '\n')
|
||
.replace(/\\r/g, '');
|
||
return decoded.replace(/<[^>]+>/g, '').trim();
|
||
}
|
||
export function decodeHtmlEntities(html) {
|
||
if (!html)
|
||
return '';
|
||
return html.replace(/ /g, ' ')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/&/g, '&')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, "'");
|
||
}
|
||
export function getHupuThreadUrl(tid) {
|
||
return `https://bbs.hupu.com/${encodeURIComponent(String(tid))}-1.html`;
|
||
}
|
||
export function getHupuSearchUrl(query, page, forum, sort) {
|
||
const searchParams = new URLSearchParams();
|
||
searchParams.append('q', String(query));
|
||
searchParams.append('page', String(page));
|
||
if (forum) {
|
||
searchParams.append('topicId', String(forum));
|
||
}
|
||
if (sort) {
|
||
searchParams.append('sortby', String(sort));
|
||
}
|
||
return `https://bbs.hupu.com/search?${searchParams.toString()}`;
|
||
}
|
||
export async function readHupuNextData(page, url, actionLabel, options = {}) {
|
||
await page.goto(url);
|
||
const result = await page.evaluate(`
|
||
(async () => {
|
||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||
const expectedTid = ${JSON.stringify(options.expectedTid || '')};
|
||
const timeoutMs = ${JSON.stringify(options.timeoutMs ?? 5000)};
|
||
let lastSeenTid = '';
|
||
let lastSeenHref = '';
|
||
|
||
const waitFor = async (predicate, limitMs = timeoutMs) => {
|
||
const start = Date.now();
|
||
while (Date.now() - start < limitMs) {
|
||
if (predicate()) return true;
|
||
await wait(100);
|
||
}
|
||
return false;
|
||
};
|
||
|
||
const ready = await waitFor(() => {
|
||
const script = document.getElementById('__NEXT_DATA__');
|
||
if (!script?.textContent) return false;
|
||
|
||
lastSeenHref = location.href;
|
||
|
||
try {
|
||
const parsed = JSON.parse(script.textContent);
|
||
const threadTid = parsed?.props?.pageProps?.detail?.thread?.tid;
|
||
lastSeenTid = typeof threadTid === 'string' ? threadTid : '';
|
||
|
||
if (!expectedTid) return true;
|
||
return threadTid === expectedTid;
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
if (!ready) {
|
||
return {
|
||
ok: false,
|
||
error: expectedTid
|
||
? \`帖子数据未就绪或tid不匹配(expected=\${expectedTid}, actual=\${lastSeenTid || 'unknown'}, href=\${lastSeenHref || location.href})\`
|
||
: '无法找到帖子数据'
|
||
};
|
||
}
|
||
|
||
try {
|
||
const text = document.getElementById('__NEXT_DATA__')?.textContent || '';
|
||
return {
|
||
ok: true,
|
||
data: JSON.parse(text)
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
ok: false,
|
||
error: error instanceof Error ? error.message : String(error)
|
||
};
|
||
}
|
||
})()
|
||
`);
|
||
if (!result || typeof result !== 'object' || !result.ok) {
|
||
throw new CommandExecutionError(`${actionLabel} failed: ${result?.error || 'invalid browser response'}`);
|
||
}
|
||
return result.data;
|
||
}
|
||
export async function readHupuSearchData(page, url, actionLabel) {
|
||
await page.goto(url);
|
||
const result = await page.evaluate(`
|
||
(async () => {
|
||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||
const waitFor = async (predicate, timeoutMs = 5000) => {
|
||
const start = Date.now();
|
||
while (Date.now() - start < timeoutMs) {
|
||
if (predicate()) return true;
|
||
await wait(100);
|
||
}
|
||
return false;
|
||
};
|
||
|
||
const extractFromScript = () => {
|
||
const marker = 'window.$$data=';
|
||
for (const script of Array.from(document.scripts)) {
|
||
const text = script.textContent || '';
|
||
const dataIndex = text.indexOf(marker);
|
||
if (dataIndex === -1) continue;
|
||
|
||
const jsonStart = dataIndex + marker.length;
|
||
let braceCount = 0;
|
||
let jsonEnd = jsonStart;
|
||
let inString = false;
|
||
let escapeNext = false;
|
||
|
||
for (let i = jsonStart; i < text.length; i++) {
|
||
const char = text[i];
|
||
|
||
if (escapeNext) {
|
||
escapeNext = false;
|
||
continue;
|
||
}
|
||
|
||
if (char === '\\\\') {
|
||
escapeNext = true;
|
||
continue;
|
||
}
|
||
|
||
if (char === '"') {
|
||
inString = !inString;
|
||
continue;
|
||
}
|
||
|
||
if (!inString) {
|
||
if (char === '{') {
|
||
braceCount++;
|
||
} else if (char === '}') {
|
||
braceCount--;
|
||
if (braceCount === 0) {
|
||
jsonEnd = i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (jsonEnd > jsonStart) {
|
||
return text.substring(jsonStart, jsonEnd + 1);
|
||
}
|
||
}
|
||
return '';
|
||
};
|
||
|
||
const ready = await waitFor(() => {
|
||
return typeof window.$$data !== 'undefined' || Boolean(extractFromScript());
|
||
});
|
||
if (!ready) {
|
||
return { ok: false, error: '无法找到搜索数据' };
|
||
}
|
||
|
||
try {
|
||
if (typeof window.$$data !== 'undefined') {
|
||
return {
|
||
ok: true,
|
||
data: JSON.parse(JSON.stringify(window.$$data))
|
||
};
|
||
}
|
||
|
||
const jsonString = extractFromScript();
|
||
return {
|
||
ok: true,
|
||
data: JSON.parse(jsonString)
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
ok: false,
|
||
error: error instanceof Error ? error.message : String(error)
|
||
};
|
||
}
|
||
})()
|
||
`);
|
||
if (!result || typeof result !== 'object' || !result.ok) {
|
||
throw new CommandExecutionError(`${actionLabel} failed: ${result?.error || 'invalid browser response'}`);
|
||
}
|
||
return result.data;
|
||
}
|
||
function buildBrowserJsonPostScript(apiUrl, body, mode) {
|
||
return `
|
||
(async () => {
|
||
const url = ${JSON.stringify(apiUrl)};
|
||
const payload = ${JSON.stringify(body)};
|
||
const mode = ${JSON.stringify(mode)};
|
||
const getCookie = (name) => document.cookie
|
||
.split('; ')
|
||
.find((item) => item.startsWith(name + '='))
|
||
?.slice(name.length + 1) || '';
|
||
|
||
const findThumbcacheValue = () => {
|
||
const rawEntry = document.cookie
|
||
.split('; ')
|
||
.find((item) => item.startsWith('.thumbcache_'));
|
||
if (rawEntry && rawEntry.includes('=')) {
|
||
const rawValue = rawEntry.slice(rawEntry.indexOf('=') + 1);
|
||
try {
|
||
return decodeURIComponent(rawValue);
|
||
} catch {
|
||
return rawValue;
|
||
}
|
||
}
|
||
|
||
const storageKey = Object.keys(localStorage).find((key) => key.startsWith('.thumbcache_'));
|
||
if (!storageKey) return '';
|
||
return localStorage.getItem(storageKey) || '';
|
||
};
|
||
|
||
const resolveDefaultPayload = (input) => {
|
||
const next = { ...input };
|
||
const sensorsRaw = decodeURIComponent(getCookie('sensorsdata2015jssdkcross') || '');
|
||
let deviceid = '';
|
||
try {
|
||
const sensors = JSON.parse(sensorsRaw);
|
||
deviceid = sensors?.props?.['$device_id'] || sensors?.distinct_id || '';
|
||
} catch {}
|
||
|
||
if ((next.puid === '' || next.puid == null) && getCookie('ua')) {
|
||
next.puid = getCookie('ua');
|
||
}
|
||
if ((next.shumei_id === '' || next.shumei_id == null) && getCookie('smidV2')) {
|
||
next.shumei_id = getCookie('smidV2');
|
||
}
|
||
if ((next.deviceid === '' || next.deviceid == null) && deviceid) {
|
||
next.deviceid = deviceid;
|
||
}
|
||
return next;
|
||
};
|
||
|
||
const resolveReplyPayload = (input) => {
|
||
const next = { ...input };
|
||
const thumbcache = findThumbcacheValue();
|
||
if ((next.shumeiId === '' || next.shumeiId == null) && thumbcache) {
|
||
next.shumeiId = thumbcache;
|
||
}
|
||
if ((next.deviceid === '' || next.deviceid == null) && thumbcache) {
|
||
next.deviceid = thumbcache;
|
||
}
|
||
return next;
|
||
};
|
||
|
||
const resolvedPayload = mode === 'reply'
|
||
? resolveReplyPayload(payload)
|
||
: resolveDefaultPayload(payload);
|
||
|
||
try {
|
||
const response = await fetch(url, {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(resolvedPayload)
|
||
});
|
||
|
||
const text = await response.text();
|
||
let data = null;
|
||
try {
|
||
data = text ? JSON.parse(text) : null;
|
||
} catch {
|
||
data = text ? { message: text } : null;
|
||
}
|
||
|
||
return {
|
||
ok: response.ok,
|
||
status: response.status,
|
||
data
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
ok: false,
|
||
error: error instanceof Error ? error.message : String(error)
|
||
};
|
||
}
|
||
})()
|
||
`;
|
||
}
|
||
/**
|
||
* Execute authenticated Hupu JSON requests inside the browser page so
|
||
* cookies and the thread referer come from the live logged-in session.
|
||
*/
|
||
export async function postHupuJson(page, tid, apiUrl, body, actionLabel, mode = 'default') {
|
||
const referer = getHupuThreadUrl(tid);
|
||
await page.goto(referer);
|
||
const result = await page.evaluate(buildBrowserJsonPostScript(apiUrl, body, mode));
|
||
if (!result || typeof result !== 'object') {
|
||
throw new CommandExecutionError(`${actionLabel} failed: invalid browser response`);
|
||
}
|
||
if (result.status === 401 || result.status === 403) {
|
||
throw new AuthRequiredError('bbs.hupu.com', `${actionLabel} failed: please log in to Hupu first`);
|
||
}
|
||
if (result.error) {
|
||
throw new CommandExecutionError(`${actionLabel} failed: ${result.error}`);
|
||
}
|
||
if (!result.ok) {
|
||
const detail = result.data?.msg || result.data?.message || `HTTP ${result.status ?? 'unknown'}`;
|
||
throw new CommandExecutionError(`${actionLabel} failed: ${detail}`);
|
||
}
|
||
return result.data ?? {};
|
||
}
|