Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

309 lines
13 KiB
JavaScript

/**
* Douyin search — keyword video search on www.douyin.com.
*
* Strategy: DOM extraction from the server-rendered search results page.
*
* Why not XHR interception:
* The `www.douyin.com/search/<q>?type=video` page renders results into
* `<ul data-e2e="scroll-list">` server-side during initial navigation
* and (for the OpenCLI-bridged browser context) does NOT fire a
* subsequent `/aweme/v1/web/general/search/single/` XHR — we confirmed
* this by `wait xhr "general/search/single"` timing out at 20s on a
* logged-in profile that has visible result cards in the DOM. Direct
* synthesis of the XHR from page context returns
* `status_code: 0, data: [], search_nil_info: { search_nil_type:
* "verify_check" }` because the bare URL lacks the SPA-computed
* `a_bogus` / `msToken` signature.
*
* DOM extraction sidesteps both blockers: the data is already in the
* rendered HTML at the moment of navigation, signature-free.
*
* Selector approach:
* Douyin obfuscates card classnames (e.g. `.ckopQfVu`, `.cIiU4Muu`)
* and they churn between builds. We pin only the stable hooks:
* - container: `[data-e2e="scroll-list"]`
* - row: `li` inside the container
* - url: `a[href*="/video/"]`
* - other fields are extracted from the row's leaf text nodes by
* SHAPE (digit+万/亿 → likes; HH:MM or MM:SS → duration; text after
* `@` → author nickname; longest remaining → desc).
*
* Output fields mirror `tiktok search` (rank, desc, author, url, plays,
* likes, comments, shares) so downstream tools that already normalize
* tiktok rows can consume douyin rows without per-adapter glue. The
* search results page only surfaces the like count — plays/comments/
* shares are not in the card markup and we expose them as 0 rather
* than fabricate values; clients that need them should fetch
* /aweme/v1/web/aweme/detail/?aweme_id=... for the relevant id.
*
* Prerequisite: the bound Chrome profile must be logged in to
* https://www.douyin.com. The search results page renders an empty
* skeleton for anonymous visitors, which we surface as AuthRequiredError.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
export const MAX_SEARCH_LIMIT = 30;
// Time budget for the SPA's initial DOM commit. Empirically the
// scroll-list `<li>` rows appear within 2-4s of navigation when logged
// in; 15s covers slow networks without blocking on a permanently-empty
// page (anonymous gate, network error).
export const RENDER_TIMEOUT_MS = 15000;
export function parseSearchLimit(raw) {
const parsed = Number(raw ?? 10);
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
throw new ArgumentError(`--limit must be an integer between 1 and ${MAX_SEARCH_LIMIT}, got ${JSON.stringify(raw)}`);
}
if (parsed < 1 || parsed > MAX_SEARCH_LIMIT) {
throw new ArgumentError(`--limit must be between 1 and ${MAX_SEARCH_LIMIT}, got ${parsed}`);
}
return parsed;
}
/**
* Parse a Douyin display count like "1.9万", "3.1万", "4702", "1.2亿"
* into a plain integer. Returns 0 for unparseable input rather than
* throwing — the CLI promises numeric columns and missing data is
* common enough on real result rows that a soft fallback is the right
* choice.
*/
export function parseDouyinCount(text) {
if (typeof text !== 'string') return 0;
const m = text.replace(/\s/g, '').match(/^(\d+(?:\.\d+)?)([万亿])?$/);
if (!m) {
const plain = Number(text.replace(/[,\s]/g, ''));
return Number.isFinite(plain) ? Math.round(plain) : 0;
}
const n = Number(m[1]);
if (!Number.isFinite(n)) return 0;
if (m[2] === '万') return Math.round(n * 10_000);
if (m[2] === '亿') return Math.round(n * 100_000_000);
return Math.round(n);
}
export function extractDouyinVideoId(href) {
if (typeof href !== 'string' || !href) return '';
let full = href;
if (full.startsWith('//')) full = 'https:' + full;
else if (full.startsWith('/')) full = 'https://www.douyin.com' + full;
try {
const parsed = new URL(full);
if (!/(^|\.)douyin\.com$/.test(parsed.hostname)) return '';
const match = parsed.pathname.match(/^\/video\/(\d+)$/);
return match?.[1] ?? '';
}
catch {
return '';
}
}
/**
* Resolve scheme-relative or absolute Douyin video links to the canonical
* https://www.douyin.com/video/<id> shape. Returns '' for unparseable
* input rather than throwing — callers expect a string column.
*/
export function normalizeDouyinVideoUrl(href) {
const id = extractDouyinVideoId(href);
return id ? `https://www.douyin.com/video/${id}` : '';
}
function isSearchCardMetadataText(text) {
if (!text) return true;
if (/^\d{1,2}:\d{2}(?::\d{2})?$/.test(text)) return true;
if (/^\d+(?:\.\d+)?[万亿]?$/.test(text)) return true;
if (/^(合集|视频|作者)$/.test(text)) return true;
if (/^(刚刚|今天|昨天|前天)$/.test(text)) return true;
if (/^\d+\s*(秒|分钟|小时|天|周|个月|月|年)前$/.test(text)) return true;
if (/^\d{4}[-/.年]\d{1,2}(?:[-/.月]\d{1,2}日?)?$/.test(text)) return true;
return false;
}
/**
* Project a single rendered card into the canonical row shape. Operates
* on a serialized card payload (the raw `{href, leafTexts}` we collect
* via page.evaluate) so this function is unit-testable without a real
* browser.
*
* `leafTexts` is the ordered list of `textContent.trim()` for every leaf
* element inside the card (no children). The fields we want are
* identified by shape:
* - duration: matches `HH:MM:SS` or `MM:SS`
* - likes: matches `<digits>(.<digits>)?(万|亿)?` and ISN'T the duration
* - author: the text node immediately following an `@` text node
* - desc: the longest remaining leaf text
*/
export function projectCard(card, index) {
const url = normalizeDouyinVideoUrl(card?.url ?? card?.href);
const texts = Array.isArray(card?.leafTexts) ? card.leafTexts.map((t) => String(t ?? '').trim()).filter(Boolean) : [];
const DURATION_RE = /^\d{1,2}:\d{2}(?::\d{2})?$/;
const COUNT_RE = /^\d+(?:\.\d+)?[万亿]?$/;
let likes = 0;
let author = '';
let longest = '';
for (let i = 0; i < texts.length; i++) {
const t = texts[i];
if (DURATION_RE.test(t)) continue;
if (!likes && COUNT_RE.test(t)) {
likes = parseDouyinCount(t);
continue;
}
if (t === '@' && !author) {
author = (texts[i + 1] ?? '').trim();
continue;
}
if (t === author) continue;
if (isSearchCardMetadataText(t)) continue;
if (t.length > longest.length) longest = t;
}
let desc = longest;
// Strip a leading "@author" that some renders fuse into the desc text node.
if (author && desc.startsWith('@' + author)) {
desc = desc.slice(author.length + 1).trim();
}
return {
rank: index + 1,
desc,
author,
url,
plays: 0,
likes,
comments: 0,
shares: 0,
};
}
function isProjectedRowUsable(row) {
return Boolean(row?.url && row?.desc);
}
export function projectSearchCards(cards, limit) {
const window = Array.isArray(cards) ? cards.slice(0, limit) : [];
const rows = window.map((card, index) => projectCard(card, index));
const invalidCount = rows.filter((row) => !isProjectedRowUsable(row)).length;
return { rows: rows.filter(isProjectedRowUsable), invalidCount };
}
// JS snippet that waits for the scroll-list to populate, then returns
// `{state: 'rendered', cards}` or `{state: 'login_wall'}` /
// `{state: 'timeout'}`. Runs inside page.evaluate so we don't pay a
// round-trip per poll iteration.
const WAIT_AND_EXTRACT_JS = (timeoutMs) => `
new Promise((resolve) => {
const collectCards = () => {
const cards = [];
const lis = document.querySelectorAll('[data-e2e="scroll-list"] li');
for (const li of lis) {
const a = li.querySelector('a[href*="/video/"]');
if (!a) continue;
const leafTexts = [];
for (const el of li.querySelectorAll('*')) {
if (el.children.length > 0) continue;
const t = (el.textContent || '').trim();
if (t) leafTexts.push(t);
}
cards.push({ href: a.getAttribute('href') || '', leafTexts });
}
return cards;
};
const detectState = () => {
const cards = collectCards();
if (cards.length > 0) return { state: 'rendered', cards };
// Anonymous gate: Douyin renders a centered "登录后查看更多内容"
// overlay on /search/ for visitors without sessionid. Match either
// the literal Chinese prompt or a visible login modal/mask.
const text = (document.body && document.body.innerText) || '';
if (/登录后查看|请先登录|登录抖音|验证码|验证|verify_check|安全校验/.test(text)) return { state: 'login_wall' };
if (/暂无相关搜索结果|没有找到相关结果|搜索结果为空|暂无结果/.test(text)) return { state: 'empty' };
const modal = document.querySelector('[class*="login-mask"], [class*="LoginMask"], [class*="login-modal"], dialog[role="dialog"]');
if (modal && modal instanceof HTMLElement) {
const r = modal.getBoundingClientRect();
const s = getComputedStyle(modal);
if (r.width > 0 && r.height > 0 && s.display !== 'none' && s.visibility !== 'hidden') {
return { state: 'login_wall' };
}
}
return null;
};
const found = detectState();
if (found) return resolve(found);
const observer = new MutationObserver(() => {
const s = detectState();
if (s) { observer.disconnect(); resolve(s); }
});
observer.observe(document.body, { childList: true, subtree: true });
setTimeout(() => {
observer.disconnect();
const fallback = detectState();
resolve(fallback ?? { state: 'timeout' });
}, ${timeoutMs});
})
`;
function unwrapEvaluateResult(payload) {
if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) {
return payload.data;
}
return payload;
}
cli({
site: 'douyin',
name: 'search',
access: 'read',
description: '关键词搜索抖音视频',
domain: 'www.douyin.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'query', required: true, positional: true, help: '搜索关键词' },
{ name: 'limit', type: 'int', default: 10, help: `结果数量 (1-${MAX_SEARCH_LIMIT})` },
],
columns: ['rank', 'desc', 'author', 'url', 'plays', 'likes', 'comments', 'shares'],
func: async (page, kwargs) => {
const limit = parseSearchLimit(kwargs.limit);
const keyword = String(kwargs.query ?? '').trim();
if (!keyword) {
throw new ArgumentError('douyin search 需要 <query> 关键词');
}
await page.goto(`https://www.douyin.com/search/${encodeURIComponent(keyword)}?type=video`);
let result;
try {
result = unwrapEvaluateResult(await page.evaluate(WAIT_AND_EXTRACT_JS(RENDER_TIMEOUT_MS)));
} catch (error) {
throw new CommandExecutionError(`Douyin search extraction failed: ${error instanceof Error ? error.message : String(error)}`);
}
if (!result || typeof result !== 'object') {
throw new CommandExecutionError('Douyin search: unexpected evaluator payload shape');
}
if (result.state === 'login_wall') {
throw new AuthRequiredError(
'www.douyin.com',
'Douyin search results are blocked behind a login wall — log in at https://www.douyin.com in Chrome first.',
);
}
if (result.state === 'empty') {
throw new EmptyResultError('douyin search', `No Douyin videos matched "${keyword}".`);
}
if (result.state === 'timeout') {
throw new CommandExecutionError('Douyin search did not render result cards within the timeout. Open the same search in Chrome and verify login/security state before retrying.');
}
if (!Array.isArray(result.cards)) {
throw new CommandExecutionError('Douyin search: evaluator returned malformed cards payload');
}
if (result.cards.length === 0) {
throw new EmptyResultError('douyin search', `No Douyin videos matched "${keyword}".`);
}
const projected = projectSearchCards(result.cards, limit);
if (projected.invalidCount > 0) {
throw new CommandExecutionError('Douyin search parser found result cards without stable video url or description');
}
if (projected.rows.length === 0) {
throw new EmptyResultError('douyin search', `No Douyin videos matched "${keyword}".`);
}
return projected.rows;
},
});