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
323 lines
12 KiB
JavaScript
323 lines
12 KiB
JavaScript
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||
|
||
const BASE_URL = 'https://www.huodongxing.com/events';
|
||
const MAX_LIMIT = 50;
|
||
export const EVENTS_COLUMNS = [
|
||
'rank',
|
||
'id',
|
||
'title',
|
||
'time',
|
||
'eventType',
|
||
'city',
|
||
'location',
|
||
'organizer',
|
||
'url',
|
||
];
|
||
|
||
function cleanText(value) {
|
||
return String(value ?? '').replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
export function requireLimit(value) {
|
||
const raw = value ?? 20;
|
||
const limit = typeof raw === 'number' ? raw : Number(String(raw).trim());
|
||
if (!Number.isInteger(limit) || limit <= 0) {
|
||
throw new ArgumentError('huodongxing limit must be a positive integer');
|
||
}
|
||
if (limit > MAX_LIMIT) {
|
||
throw new ArgumentError(`huodongxing limit must be <= ${MAX_LIMIT}`);
|
||
}
|
||
return limit;
|
||
}
|
||
|
||
function appendIfPresent(params, name, value) {
|
||
const text = cleanText(value);
|
||
if (text) params.set(name, text);
|
||
}
|
||
|
||
function dateOrdinal(year, month, day) {
|
||
return Math.floor(Date.UTC(year, month - 1, day) / 86400000);
|
||
}
|
||
|
||
function parseYmd(value) {
|
||
const match = cleanText(value).match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||
if (!match) return null;
|
||
const year = Number(match[1]);
|
||
const month = Number(match[2]);
|
||
const day = Number(match[3]);
|
||
const date = new Date(Date.UTC(year, month - 1, day));
|
||
if (
|
||
date.getUTCFullYear() !== year
|
||
|| date.getUTCMonth() !== month - 1
|
||
|| date.getUTCDate() !== day
|
||
) {
|
||
return null;
|
||
}
|
||
return dateOrdinal(year, month, day);
|
||
}
|
||
|
||
function requireYmdArg(value, label) {
|
||
const text = cleanText(value);
|
||
if (!text) return '';
|
||
if (parseYmd(text) == null) {
|
||
throw new ArgumentError(`huodongxing ${label} must be a valid YYYY-MM-DD date`);
|
||
}
|
||
return text;
|
||
}
|
||
|
||
function requireDateRangeArgs(args = {}) {
|
||
const date = requireYmdArg(args.date, 'date');
|
||
const dateTo = requireYmdArg(args.dateTo, 'dateTo');
|
||
if (date && dateTo && parseYmd(date) > parseYmd(dateTo)) {
|
||
throw new ArgumentError('huodongxing date must be <= dateTo');
|
||
}
|
||
return { date, dateTo };
|
||
}
|
||
|
||
function unwrapEvaluateResult(payload) {
|
||
if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) {
|
||
return payload.data;
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
function localDateOrdinal(date) {
|
||
return dateOrdinal(date.getFullYear(), date.getMonth() + 1, date.getDate());
|
||
}
|
||
|
||
function parseMonthDayToken(token, year) {
|
||
const match = cleanText(token).match(/(\d{1,2})\/(\d{1,2})/);
|
||
if (!match) return null;
|
||
return {
|
||
month: Number(match[1]),
|
||
day: Number(match[2]),
|
||
ordinal: dateOrdinal(year, Number(match[1]), Number(match[2])),
|
||
};
|
||
}
|
||
|
||
function parseEventTimeRange(timeText, options = {}) {
|
||
const text = cleanText(timeText);
|
||
if (!text) return null;
|
||
const filterStart = parseYmd(options.date);
|
||
const filterEnd = parseYmd(options.dateTo) ?? filterStart;
|
||
const referenceDate = options.referenceDate instanceof Date ? options.referenceDate : new Date();
|
||
const referenceOrdinal = localDateOrdinal(referenceDate);
|
||
if (/今天/.test(text)) return { start: referenceOrdinal, end: referenceOrdinal };
|
||
if (/明天/.test(text)) return { start: referenceOrdinal + 1, end: referenceOrdinal + 1 };
|
||
if (/后天/.test(text)) return { start: referenceOrdinal + 2, end: referenceOrdinal + 2 };
|
||
|
||
const baseYear = options.year ?? (
|
||
cleanText(options.date).match(/^(\d{4})-/)?.[1]
|
||
? Number(cleanText(options.date).slice(0, 4))
|
||
: referenceDate.getFullYear()
|
||
);
|
||
const tokens = Array.from(text.matchAll(/(\d{1,2}\/\d{1,2})/g)).map((match) => match[1]);
|
||
if (tokens.length === 0) return null;
|
||
const start = parseMonthDayToken(tokens[0], baseYear);
|
||
if (!start) return null;
|
||
if (tokens.length === 1) return { start: start.ordinal, end: start.ordinal };
|
||
const endYear = parseMonthDayToken(tokens[1], baseYear)?.month < start.month ? baseYear + 1 : baseYear;
|
||
const end = parseMonthDayToken(tokens[1], endYear);
|
||
if (!end) return { start: start.ordinal, end: start.ordinal };
|
||
|
||
// If the parsed range is clearly before the requested window, try the next
|
||
// year. Huodongxing listings omit the year, but searches can cross New Year.
|
||
if (filterStart != null && end.ordinal < filterStart && start.month >= 10) {
|
||
const nextStart = parseMonthDayToken(tokens[0], baseYear + 1);
|
||
const nextEndYear = parseMonthDayToken(tokens[1], baseYear + 1)?.month < nextStart.month ? baseYear + 2 : baseYear + 1;
|
||
const nextEnd = parseMonthDayToken(tokens[1], nextEndYear);
|
||
if (nextStart && nextEnd) return { start: nextStart.ordinal, end: nextEnd.ordinal };
|
||
}
|
||
return { start: start.ordinal, end: end.ordinal };
|
||
}
|
||
|
||
export function filterRowsByDateRange(rows, options = {}) {
|
||
requireDateRangeArgs(options);
|
||
const filterStart = parseYmd(options.date);
|
||
const filterEnd = parseYmd(options.dateTo) ?? filterStart;
|
||
if (filterStart == null && filterEnd == null) return rows;
|
||
const start = filterStart ?? filterEnd;
|
||
const end = filterEnd ?? filterStart;
|
||
const filtered = rows.filter((row) => {
|
||
const range = parseEventTimeRange(row.time, options);
|
||
if (!range) return true;
|
||
return range.start <= end && range.end >= start;
|
||
});
|
||
return filtered.map((row, index) => ({ ...row, rank: index + 1 }));
|
||
}
|
||
|
||
export function buildEventsUrl(args = {}) {
|
||
const dateRange = requireDateRangeArgs(args);
|
||
const params = new URLSearchParams();
|
||
params.set('orderby', 'o');
|
||
params.set('d', 'ts');
|
||
appendIfPresent(params, 'date', dateRange.date);
|
||
appendIfPresent(params, 'dateTo', dateRange.dateTo);
|
||
appendIfPresent(params, 'tag', args.tag);
|
||
appendIfPresent(params, 'city', args.city);
|
||
const eventType = cleanText(args.eventType);
|
||
if (eventType) {
|
||
if (eventType !== '1' && eventType !== '2') {
|
||
throw new ArgumentError('huodongxing eventType must be 1 (offline) or 2 (online)');
|
||
}
|
||
params.set('eventType', eventType);
|
||
}
|
||
appendIfPresent(params, 'qs', args.qs);
|
||
return `${BASE_URL}?${params.toString()}`;
|
||
}
|
||
|
||
export function extractEventRowsPayload(limit = 20) {
|
||
const maxLimit = 50;
|
||
const rawLimit = limit ?? 20;
|
||
const count = typeof rawLimit === 'number' ? rawLimit : Number(String(rawLimit).trim());
|
||
if (!Number.isInteger(count) || count <= 0 || count > maxLimit) {
|
||
return {
|
||
ok: false,
|
||
code: 'INVALID_LIMIT',
|
||
message: `huodongxing limit must be a positive integer <= ${maxLimit}`,
|
||
};
|
||
}
|
||
const clean = (value) => String(value ?? '').replace(/\s+/g, ' ').trim();
|
||
const text = (root, selector) => clean(root.querySelector(selector)?.textContent ?? '');
|
||
const idFromHref = (href) => {
|
||
const match = String(href ?? '').match(/\/event\/(\d+)/);
|
||
return match ? match[1] : '';
|
||
};
|
||
const eventUrl = (id) => `https://www.huodongxing.com/event/${id}`;
|
||
const kind = (card, location) => {
|
||
if (card.querySelector('.item-live-icon')) return 'online';
|
||
if (card.querySelector('.item-dress-icon')) return 'offline';
|
||
if (/线上|在线|直播/.test(location)) return 'online';
|
||
return '';
|
||
};
|
||
const cards = Array.from(document.querySelectorAll('.search-tab-content-item-mesh'));
|
||
const rows = [];
|
||
const seen = new Set();
|
||
|
||
for (const card of cards) {
|
||
if (rows.length >= count) break;
|
||
const link = card.querySelector('a.item-title[href*="/event/"], a[href*="/event/"]');
|
||
const id = idFromHref(link?.getAttribute('href') ?? '');
|
||
if (!id || seen.has(id)) continue;
|
||
|
||
const title = clean(
|
||
text(card, 'a.item-title span')
|
||
|| text(card, 'a.item-title')
|
||
|| card.querySelector('img.item-logo')?.getAttribute('alt')
|
||
|| '',
|
||
);
|
||
if (!title) continue;
|
||
|
||
const time = text(card, '.item-dress p');
|
||
const location = text(card, '.item-dress-pp');
|
||
const organizer = clean(
|
||
text(card, '.item-bottom-left .user-name')
|
||
|| card.querySelector('.item-bottom-left img[title]')?.getAttribute('title')
|
||
|| card.querySelector('.item-bottom-left img[alt]')?.getAttribute('alt')
|
||
|| '',
|
||
);
|
||
seen.add(id);
|
||
rows.push({
|
||
rank: rows.length + 1,
|
||
id,
|
||
title,
|
||
time,
|
||
eventType: kind(card, location),
|
||
city: location,
|
||
location,
|
||
organizer,
|
||
url: eventUrl(id),
|
||
});
|
||
}
|
||
|
||
if (rows.length === 0) {
|
||
const pageText = clean(document.body?.textContent ?? '');
|
||
if (/操作过于频繁|too\s+frequent|rate\s*limit/i.test(pageText)) {
|
||
return {
|
||
ok: false,
|
||
code: 'RATE_LIMIT',
|
||
message: 'huodongxing events was rate limited by www.huodongxing.com',
|
||
hint: 'Wait a few minutes and retry from a real browser session.',
|
||
};
|
||
}
|
||
if (cards.length > 0) {
|
||
return {
|
||
ok: false,
|
||
code: 'MALFORMED_RESULT',
|
||
message: 'huodongxing events cards were found but no stable event rows could be extracted',
|
||
hint: 'Huodongxing card markup changed: expected event links with /event/<id> and non-empty titles.',
|
||
};
|
||
}
|
||
return {
|
||
ok: false,
|
||
code: 'EMPTY_RESULT',
|
||
message: 'huodongxing events returned no data',
|
||
hint: 'No Huodongxing event cards were found. The page may be empty or the DOM structure may have changed.',
|
||
};
|
||
}
|
||
return { ok: true, rows };
|
||
}
|
||
|
||
function requireExtractionRows(result) {
|
||
const payload = unwrapEvaluateResult(result);
|
||
if (payload?.ok === true && Array.isArray(payload.rows)) return payload.rows;
|
||
if (payload?.ok === true) {
|
||
throw new CommandExecutionError(
|
||
'huodongxing events returned malformed extraction rows',
|
||
'Expected browser extraction payload rows to be an array.',
|
||
);
|
||
}
|
||
if (payload?.code === 'RATE_LIMIT') {
|
||
throw new CommandExecutionError(
|
||
payload.message || 'huodongxing events was rate limited by www.huodongxing.com',
|
||
payload.hint || 'Wait a few minutes and retry from a real browser session.',
|
||
);
|
||
}
|
||
if (payload?.code === 'INVALID_LIMIT') {
|
||
throw new ArgumentError(payload.message || 'huodongxing limit must be a positive integer <= 50');
|
||
}
|
||
if (payload?.code === 'EMPTY_RESULT') {
|
||
throw new EmptyResultError(
|
||
'huodongxing events',
|
||
payload.hint || 'No Huodongxing event cards were found. The page may be empty.',
|
||
);
|
||
}
|
||
throw new CommandExecutionError(
|
||
payload?.message || 'huodongxing events returned malformed extraction data',
|
||
payload?.hint || 'Huodongxing extraction did not return the expected structured payload.',
|
||
);
|
||
}
|
||
|
||
export function extractEventRows(limit = 20) {
|
||
return requireExtractionRows(extractEventRowsPayload(limit));
|
||
}
|
||
|
||
cli({
|
||
site: 'huodongxing',
|
||
name: 'events',
|
||
access: 'read',
|
||
description: '活动行活动搜索(按标签、城市、日期、线上/线下、名称过滤)',
|
||
domain: 'www.huodongxing.com',
|
||
strategy: Strategy.PUBLIC,
|
||
browser: true,
|
||
args: [
|
||
{ name: 'tag', type: 'string', default: '', help: '活动标签,例如 AI' },
|
||
{ name: 'city', type: 'string', default: '全部', help: '城市名,例如 北京 / 上海 / 全部' },
|
||
{ name: 'date', type: 'string', default: '', help: '开始日期,格式 YYYY-MM-DD' },
|
||
{ name: 'dateTo', type: 'string', default: '', help: '结束日期,格式 YYYY-MM-DD' },
|
||
{ name: 'eventType', type: 'int', default: undefined, help: '活动类型:1 线下,2 线上' },
|
||
{ name: 'qs', type: 'string', default: '', help: '按活动名称关键词过滤' },
|
||
{ name: 'limit', type: 'int', default: 20, help: '返回条数(1-50)' },
|
||
],
|
||
columns: EVENTS_COLUMNS,
|
||
func: async (page, args) => {
|
||
const limit = requireLimit(args.limit);
|
||
const url = buildEventsUrl(args);
|
||
await page.goto(url);
|
||
await page.wait(2);
|
||
const result = await page.evaluate(`(${extractEventRowsPayload.toString()})(${limit})`);
|
||
return filterRowsByDateRange(requireExtractionRows(result), args);
|
||
},
|
||
});
|