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

323 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
},
});