Skip to content

feat(route): add Anhui Museum news and exhibition route - #23112

Merged
TonyRL merged 4 commits into
DIYgod:masterfrom
magazian:feat-ahm
Sep 19, 2026
Merged

TonyRL merged 4 commits into
DIYgod:masterfrom
magazian:feat-ahm

Conversation

@magazian

@magazian magazian commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Involved Issue / 该 PR 相关 Issue

Close #

Example for the Proposed Route(s) / 路由地址示例

/ahm/exhibition/xztj
/ahm/news/abxw

New RSS Route Checklist / 新 RSS 路由检查表

  • New Route / 新的路由
  • Anti-bot or rate limit / 反爬/频率限制
    • If yes, do your code reflect this sign? / 如果有, 是否有对应的措施?
  • Date and time / 日期和时间
    • Parsed / 可以解析
    • Correct time zone / 时区正确
  • New package added / 添加了新的包
  • Puppeteer

Note / 说明

@github-actions github-actions Bot added the route label Aug 25, 2026
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Auto Review

[Rule 13/24 — parseDate & time zone]

  • lib/routes/ahm/xztj.tsx: dates go through dayjs(dateStr).format(...) and then parseDate(startDate) with no time-zone correction, while lib/routes/ahm/abxw.ts correctly wraps with timezone(parseDate(...), 8). Source dates are Beijing time, so pubDate here is off by 8 hours. Fix: drop the dayjs round-trip and use pubDate: startDate ? timezone(parseDate(startDate), 8) : undefined;, adding an import of timezone from @/utils/timezone.

[Rule 11/22 — description content]

  • lib/routes/ahm/xztj.tsx: the rendered description repeats pubDate (the 开展: line is exactly the value set as pubDate) and repeats the start/end dates again via 原始展期:{fullDuration}. Fix: drop the 开展: block and the duplicated 原始展期 paragraph, keeping only content not already carried by dedicated fields (image, 地点, 闭展).

Resolved since the last review: the duplicate-guid fallback in xztj.tsx is now guarded by an early empty-array return.

@github-actions

Copy link
Copy Markdown
Contributor

Successfully generated as following:

http://localhost:1200/ahm/exhibition/xztj - Failed ❌
HTTPError: Response code 503 (Service Unavailable)

Error Message:<br/>TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
  - waiting for locator(&#39;ul.exhibition-new li&#39;) to be visible
Route: /ahm/exhibition/xztj
Full Route: /ahm/exhibition/xztj
Node Version: v24.19.0
Git Hash: eeac2f50
http://localhost:1200/ahm/news/abxw - Failed ❌
HTTPError: Response code 503 (Service Unavailable)

Error Message:<br/>TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
  - waiting for locator(&#39;ul.img-cont-list li&#39;) to be visible
Route: /ahm/news/abxw
Full Route: /ahm/news/abxw
Node Version: v24.19.0
Git Hash: eeac2f50

@github-actions github-actions Bot added the auto: not ready to review Users can't get the RSS feed output according to automated testing results label Aug 25, 2026
@magazian

Copy link
Copy Markdown
Contributor Author
image image the route works locally.

@github-actions github-actions Bot added auto: not ready to review Users can't get the RSS feed output according to automated testing results and removed auto: not ready to review Users can't get the RSS feed output according to automated testing results labels Aug 25, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Successfully generated as following:

http://localhost:1200/ahm/exhibition/xztj - Failed ❌
HTTPError: Response code 503 (Service Unavailable)

Error Message:<br/>TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
  - waiting for locator(&#39;ul.exhibition-new li&#39;) to be visible
Route: /ahm/exhibition/xztj
Full Route: /ahm/exhibition/xztj
Node Version: v24.19.0
Git Hash: b32afd48
http://localhost:1200/ahm/news/abxw - Failed ❌
HTTPError: Response code 503 (Service Unavailable)

Error Message:<br/>TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
  - waiting for locator(&#39;ul.img-cont-list li&#39;) to be visible
Route: /ahm/news/abxw
Full Route: /ahm/news/abxw
Node Version: v24.19.0
Git Hash: b32afd48

@TonyRL TonyRL left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Playwright is not necessary. It can be solved without the aid of a browser.

Update the base branch and add

ct2-waap.ts
import { createCipheriv, createDecipheriv, randomUUID } from 'node:crypto';

import { evaluateScriptData } from '@/utils/evaluate-script';
import md5 from '@/utils/md5';
import ofetch from '@/utils/ofetch';

interface Challenge {
    salt: string;
    envKey: string;
    queryKey: string;
    envCookie: string;
    queryCookie: string;
    bundleMd5: string;
}

const challengeCache = new Map<string, Challenge>();

const hex = (s: string) => (s.startsWith('-') ? -Number(s.slice(1)) : Number(s));
const argRe = /^(\w+)(?:-\s?(-?0x[0-9a-f]+))?$/;

const decodeStrings = async (bundle: string) => {
    const dec = bundle.match(/function (\w+)\((\w+),\w+\)\{\2=\2-(0x[0-9a-fA-F]+);var \w+=(\w+)\(\);/);
    if (!dec) {
        throw new Error('CT2-WAAP: string decoder missing');
    }

    const [, decoderName, , baseHex, tableFn] = dec;
    const tableStart = bundle.indexOf(`function ${tableFn}(){var `);
    const tableEnd = bundle.indexOf(`];${tableFn}=function`, tableStart);
    if (tableStart === -1 || tableEnd === -1) {
        throw new Error('CT2-WAAP: string table missing');
    }

    const table = await evaluateScriptData<string[]>(`${bundle.slice(tableStart + `function ${tableFn}(){`.length, tableEnd + 1)};`, bundle.slice(tableStart, tableEnd).match(/var (\w+)=\[/)![1]);
    const base = Number(baseHex);

    const wrappers = new Map<string, { params: string[]; callee: string; args: Array<{ p: string; off: number }> }>();
    for (const m of bundle.matchAll(/function (\w+)\(([\w,]*)\)\{return (\w+)\(([^()]*)\);\}/g)) {
        const args = m[4].split(',').map((a) => a.match(argRe));
        if (args.every((a) => a !== null)) {
            wrappers.set(m[1], { params: m[2].split(','), callee: m[3], args: args.map((a) => ({ p: a[1], off: a[2] ? hex(a[2]) : 0 })) });
        }
    }

    const decode = (name: string, vals: number[], depth = 0): string | undefined => {
        if (name === decoderName) {
            return table[vals[0] - base];
        }
        const w = wrappers.get(name);
        if (!w || depth > 16) {
            return undefined;
        }

        const env = Object.fromEntries(w.params.map((p, i) => [p, vals[i]]));
        return decode(
            w.callee,
            w.args.map((a) => env[a.p] - a.off),
            depth + 1
        );
    };

    return bundle.replaceAll(/\b(\w+)\((-?0x[0-9a-f]+(?:,-?0x[0-9a-f]+)*)\)/g, (call, name: string, args: string) => {
        const value =
            name === decoderName || wrappers.has(name)
                ? decode(
                      name,
                      args.split(',').map((a) => hex(a))
                  )
                : undefined;
        return value === undefined ? call : JSON.stringify(value);
    });
};

const q = String.raw`["']`;

const analyzeBundle = async (bundle: string): Promise<Challenge> => {
    const plain = await decodeStrings(bundle);
    const salt = plain.match(new RegExp(String.raw`\[${q}slice${q}\]\(0x0,0x4\)\+${q}([^"']+)${q}`));
    const keys = plain.match(new RegExp(String.raw`${q}env${q}===\w+\?\w+\[${q}xyjgnaksfLocal${q}\]\[${q}(\w+)${q}\]:\w+\[${q}xyjgnaksfLocal${q}\]\[${q}(\w+)${q}\]`));

    const cookieVar = (role: string) => {
        const call = plain.match(new RegExp(String.raw`\(${q}${role}${q},(\w+),\w+\)`));
        const lit = call && plain.match(new RegExp(String.raw`[,;\s]${call[1]}=${q}(CT_\w+)${q}`));
        return lit?.[1];
    };
    const envCookie = cookieVar('env');
    const queryCookie = cookieVar('query');
    if (!salt || !keys || !envCookie || !queryCookie) {
        throw new Error('CT2-WAAP: bundle structure changed');
    }

    return {
        salt: salt[1],
        envKey: keys[1],
        queryKey: keys[2],
        envCookie,
        queryCookie,
        bundleMd5: md5(bundle),
    };
};

const aes = (key: string) => ({
    decrypt: (b64: string) => {
        const d = createDecipheriv('aes-128-cbc', key, key);
        return Buffer.concat([d.update(b64, 'base64'), d.final()]).toString('utf8');
    },
    encrypt: (text: string) => {
        const c = createCipheriv('aes-128-cbc', key, key);
        return Buffer.concat([c.update(text, 'utf8'), c.final()]).toString('base64');
    },
});

const alnum = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const randomString = (n: number) => Array.from({ length: n }, () => alnum[Math.floor(Math.random() * alnum.length)]).join('');

const visitorId = () => {
    const uuid = randomUUID();
    let sum = 0;
    for (const ch of uuid.replaceAll('-', '')) {
        sum += Number.parseInt(ch, 16) % 10;
    }
    const head = String(sum).slice(0, 3).padStart(3, '0');
    const parts = uuid.split('-');
    parts.splice(1, 0, head + randomString(8 - head.length));
    return parts.join('-');
};

const buildCookies = (c: Challenge, encStr: string) => {
    const cfg = JSON.parse(aes(encStr.slice(0, 4) + c.salt).decrypt(encStr.slice(4))) as Record<string, unknown>;
    const serverTime = Object.values(cfg).find((v) => typeof v === 'string' && /^\d{10}$/.test(v)) as string | undefined;
    const envKey = cfg[c.envKey];
    const queryKey = cfg[c.queryKey];
    if (!serverTime || typeof envKey !== 'string' || typeof queryKey !== 'string') {
        throw new Error('CT2-WAAP: challenge config changed');
    }
    const last2 = serverTime.slice(-2);
    const wrap = (data: string) => `002&&${serverTime}&&${data}&&${last2}`;
    return {
        [c.envCookie]: aes(envKey.slice(3, 19)).encrypt(wrap(`envCTCT&&${visitorId()}&&0&&allRight`)),
        [c.queryCookie]: aes(queryKey.slice(3, 19)).encrypt(wrap(`queryCTCT&&${randomString(2)}&&${c.bundleMd5}`)),
    };
};

export const fetchPage = async (pageUrl: string): Promise<string> => {
    const first = await ofetch.raw(pageUrl, {
        responseType: 'text',
        ignoreResponseError: true,
    });
    const html = first._data ?? '';
    if (first.status === 200 && !html.includes('ctct_bundle')) {
        return html;
    }

    const scripts = Array.from(html.matchAll(/<script[^>]+src="([^"]+)"/g), (m) => m[1]);
    const xyPath = scripts.find((s) => !s.includes('ctct_bundle'));
    const bundlePath = scripts.find((s) => s.includes('ctct_bundle'));
    if (!xyPath || !bundlePath) {
        throw new Error(`CT2-WAAP: unexpected status ${first.status} for ${pageUrl}`);
    }

    const bundleUrl = new URL(bundlePath, pageUrl).href;
    const [xy, bundleText] = await Promise.all([ofetch(new URL(xyPath, pageUrl).href, { responseType: 'text' }), ofetch(bundleUrl, { responseType: 'text' })]);

    const candidates = xy.matchAll(/var (\w+)\s*=\s*"((?:[^"\\]|\\.)*)"/g).toArray();
    candidates.sort((a, b) => b[2].length - a[2].length);
    if (!candidates.length) {
        throw new Error('CT2-WAAP: challenge config missing');
    }

    let challenge = challengeCache.get(bundleUrl);
    if (!challenge) {
        if (challengeCache.size > 5) {
            challengeCache.clear();
        }
        challenge = await analyzeBundle(bundleText);
        challengeCache.set(bundleUrl, challenge);
    }

    const cookie = Object.entries(buildCookies(challenge, candidates[0][2]))
        .map(([k, v]) => `${k}=${v}`)
        .join('; ');

    const second = await ofetch.raw(pageUrl, {
        headers: { Cookie: cookie },
        responseType: 'text',
        ignoreResponseError: true,
    });
    if (second.status !== 200) {
        challengeCache.delete(bundleUrl);
        throw new Error(`CT2-WAAP: challenge failed with ${second.status} for ${pageUrl}`);
    }

    return second._data ?? '';
};
and
const html = await fetchPage(listUrl);

@github-actions github-actions Bot added auto: not ready to review Users can't get the RSS feed output according to automated testing results and removed auto: not ready to review Users can't get the RSS feed output according to automated testing results labels Sep 19, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Successfully generated as following:

http://localhost:1200/ahm/exhibition/xztj - Failed ❌
HTTPError: Response code 503 (Service Unavailable)

Error Message:<br/>TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
  - waiting for locator(&#39;ul.exhibition-new li&#39;) to be visible
Route: /ahm/exhibition/xztj
Full Route: /ahm/exhibition/xztj
Node Version: v24.21.0
Git Hash: 200c5f39
http://localhost:1200/ahm/news/abxw - Failed ❌
HTTPError: Response code 503 (Service Unavailable)

Error Message:<br/>TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
  - waiting for locator(&#39;ul.img-cont-list li&#39;) to be visible
Route: /ahm/news/abxw
Full Route: /ahm/news/abxw
Node Version: v24.21.0
Git Hash: 200c5f39

@github-actions github-actions Bot added auto: not ready to review Users can't get the RSS feed output according to automated testing results and removed auto: not ready to review Users can't get the RSS feed output according to automated testing results labels Sep 19, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Successfully generated as following:

http://localhost:1200/ahm/exhibition/xztj - Failed ❌
HTTPError: Response code 503 (Service Unavailable)

Error Message:<br/>Error: CT2-WAAP: unexpected status 405 for https://www.ahm.cn/Exhibition/TListNow/xztj
Route: /ahm/exhibition/xztj
Full Route: /ahm/exhibition/xztj
Node Version: v24.21.0
Git Hash: 5e521ff1
http://localhost:1200/ahm/news/abxw - Failed ❌
HTTPError: Response code 503 (Service Unavailable)

Error Message:<br/>Error: CT2-WAAP: unexpected status 405 for https://www.ahm.cn/News/List/abxw
Route: /ahm/news/abxw
Full Route: /ahm/news/abxw
Node Version: v24.21.0
Git Hash: 5e521ff1

@TonyRL
TonyRL merged commit f99e982 into DIYgod:master Sep 19, 2026
43 of 45 checks passed
@magazian
magazian deleted the feat-ahm branch September 20, 2026 02:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto: not ready to review Users can't get the RSS feed output according to automated testing results route

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants