fix(route/zhihu): generate __zse_ck with JSDOM (#22319)

* fix(route/zhihu): obtain __zse_ck from a browser session

`__zse_ck` is computed at runtime by Zhihu's JS from the device fingerprint
and `d_c0`, rotates every few days, and is cross-checked against `d_c0` by
the backend, so it has to come from a real browser session. Drive a browser
seeded with the configured cookies (including the `z_c0` login cookie that
most endpoints now require) to compute a fresh, consistent `__zse_ck`,
harvest the cookie jar, and cache it for 30 minutes so it is refreshed
automatically.

Recommended ZHIHU_COOKIES: "d_c0=...; z_c0=..." (omit `__zse_ck`).

* fix(route/zhihu): fetch posts profile via API instead of HTML

The user's HTML homepage (www.zhihu.com/people/:id) is now rate-limited
(403) more aggressively than the API, which broke /zhihu/posts on the
profile fetch even though the article-list API works. Read the profile
(name, headline, avatar) from /api/v4/members/:id instead.

* fix(route/zhihu): generate __zse_ck with JSDOM

* fix(route/zhihu): use members API for org posts

---------

Co-authored-by: DzmingLi <news@dzming.li>
This commit is contained in:
Dzming Li
2026-07-13 19:21:57 +08:00
committed by GitHub
co-authored by DzmingLi
parent a6708ba74a
commit 9d85d44492
2 changed files with 179 additions and 71 deletions
+13 -10
View File
@@ -1,11 +1,9 @@
import { load } from 'cheerio';
import type { Route } from '@/types';
import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
import type { Articles, Profile } from './types';
import type { Articles } from './types';
import { getSignedHeader, header, processImage } from './utils';
export const route: Route = {
@@ -45,21 +43,26 @@ async function handler(ctx) {
const usertype = ctx.req.param('usertype');
const userProfile = await cache.tryGet(`zhihu:posts:profile:${id}`, async () => {
const userAPIPath = `/${usertype === 'people' ? 'people' : 'org'}/${id}`;
// Read the profile from the API instead of scraping the user's HTML
// homepage, which is now rate-limited (403) more aggressively than the API.
const profileApiPath = `/api/v4/members/${id}`;
const result = await ofetch(`https://www.zhihu.com${userAPIPath}`, {
const result = await ofetch(`https://www.zhihu.com${profileApiPath}`, {
headers: {
...header,
...(await getSignedHeader(`https://www.zhihu.com/${usertype}/${id}/`, userAPIPath)),
...(await getSignedHeader(`https://www.zhihu.com/${usertype}/${id}/`, profileApiPath)),
Referer: `https://www.zhihu.com/${usertype}/${id}/`,
},
});
const $ = load(result);
const data = JSON.parse($('#js-initialData').text());
return data?.initialState?.entities?.users[id] as Profile;
return {
name: result.name,
headline: result.headline,
avatarUrl: result.avatar_url,
};
});
const apiPath = `/api/v4/${usertype === 'people' ? 'members' : 'org'}/${id}/articles?${new URLSearchParams({
const apiPath = `/api/v4/members/${id}/articles?${new URLSearchParams({
include:
'data[*].comment_count,suggest_edit,is_normal,thumbnail_extra_info,thumbnail,can_comment,comment_permission,admin_closed_comment,content,voteup_count,created,updated,upvoted_followees,voting,review_info,reaction_instruction,is_labeled,label_info;data[*].vessay_info;data[*].author.badge[?(type=best_answerer)].topics;data[*].author.vip_info;',
offset: '0',
+166 -61
View File
@@ -1,9 +1,14 @@
import { Script } from 'node:vm';
import { load } from 'cheerio';
import { JSDOM, VirtualConsole } from 'jsdom';
import { config } from '@/config';
import cache from '@/utils/cache';
import { generateHeaders } from '@/utils/header-generator';
import md5 from '@/utils/md5';
import ofetch from '@/utils/ofetch';
import wait from '@/utils/wait';
import { encrypt as g_encrypt } from './execlib/x-zse-96-v3';
@@ -58,87 +63,187 @@ export const processImage = (content: string) => {
return $.html();
};
export const getCookieValueByKey = (key: string) =>
config.zhihu.cookies
const getCookieValueFrom = (cookieStr: string | undefined, key: string) =>
cookieStr
?.split(';')
.map((e) => e.trim())
.find((e) => e.startsWith(key + '='))
?.slice(key.length + 1) || '';
export const getSignedHeader = async (url: string, apiPath: string) => {
if (config?.zhihu?.cookies) {
const dc0 = getCookieValueByKey('d_c0');
export const getCookieValueByKey = (key: string) => getCookieValueFrom(config.zhihu.cookies as string | undefined, key);
const xzse93 = '101_3_3.0';
const f = `${xzse93}+${apiPath}+${dc0}`;
const xzse96 = '2.0_' + g_encrypt(md5(f));
let isUnreachableRuntimeErrorGuarded = false;
const pendingZseCredentials = new Map<string, Promise<{ dc0: string; zseCk: string; ua: string }>>();
// If __zse_ck is absent from ZHIHU_COOKIES, fetch it automatically from
// Zhihu's public static JS. The value is site-wide (not user-specific)
// and requires no login, but it expires and must be kept up to date.
let cookieStr = config.zhihu.cookies;
if (!getCookieValueByKey('__zse_ck')) {
const zseCk = await cache.tryGet('zhihu:zse_ck', async () => {
const response = await ofetch.raw('https://static.zhihu.com/zse-ck/v3.js');
const script = await response._data.text();
return script.match(/__g\.ck\|\|"([\w+/=\\]*)",_=/)?.[1] || '';
});
if (zseCk) {
cookieStr += `; __zse_ck=${zseCk}`;
}
}
return {
cookie: cookieStr,
'x-zse-96': xzse96,
'x-app-za': 'OS=Web',
'x-zse-93': xzse93,
};
const preventUnreachableRuntimeError = () => {
if (isUnreachableRuntimeErrorGuarded) {
return;
}
// NOTICE: this method is out of date.
// Because the API of zhihu.com has changed, we must use the value of `d_c0` (extracted from cookies) to calculate
// `x-zse-96`. So first get `d_c0`, then get the actual data of a ZhiHu question. In this way, we don't need to
// require users to set the cookie in environmental variables anymore.
// fisrt: get cookie(dc_0) from zhihu.com
const { dc0, zseCk } = await cache.tryGet('zhihu:cookies:d_c0', async () => {
if (getCookieValueByKey('d_c0') && getCookieValueByKey('__zse_ck')) {
return { dc0: getCookieValueByKey('d_c0'), zseCk: getCookieValueByKey('__zse_ck') };
isUnreachableRuntimeErrorGuarded = true;
process.on('unhandledRejection', (reason) => {
const error = reason as { name?: string; message?: string } | undefined;
if (error?.name === 'RuntimeError' && error.message === 'unreachable') {
return;
}
const response1 = await ofetch.raw('https://static.zhihu.com/zse-ck/v3.js');
const script = await response1._data.text();
const zseCk = script.match(/__g\.ck\|\|"([\w+/=\\]*)",_=/)?.[1];
const response2 = zseCk
? await ofetch.raw(url, {
headers: {
cookie: `${response1.headers
.getSetCookie()
.map((s) => s.split(';', 1)[0])
.join('; ')}; __zse_ck=${zseCk}`,
},
})
: null;
throw reason;
});
};
const dc0 =
(response2 || response1).headers
.getSetCookie()
.find((s) => s.startsWith('d_c0='))
const generateZseCk = async (url: string, apiPath: string, configuredDc0: string) => {
preventUnreachableRuntimeError();
// `__zse_ck` is checked against the user-agent that generated it.
const ua = generateHeaders()['user-agent'];
const headers = { 'user-agent': ua };
let dc0 = configuredDc0;
if (!dc0) {
const seed = await ofetch.raw('https://www.zhihu.com/explore', {
headers,
redirect: 'manual',
ignoreResponseError: true,
});
dc0 =
(seed.headers.getSetCookie?.() ?? [])
.find((line) => line.startsWith('d_c0='))
?.split(';', 1)[0]
.trim()
.slice('d_c0='.length) || '';
}
if (!dc0) {
throw new Error('zhihu: failed to obtain a guest d_c0 cookie');
}
return { dc0, zseCk };
const challenge = await ofetch.raw(`https://www.zhihu.com${apiPath}`, {
headers: {
...headers,
cookie: `d_c0=${dc0}; __zse_ck=005_x-x`,
referer: url,
'x-requested-with': 'fetch',
},
ignoreResponseError: true,
});
const html = challenge._data as string;
const meta = html.match(/id="zh-zse-ck"[^>]*content="([^"]*)"/)?.[1];
const hash = html.match(/zse-ck\/v4\/([a-f0-9]+)\.js/)?.[1];
if (!meta || !hash) {
throw new Error('zhihu: challenge page did not contain an URL to __zse_ck meta/script');
}
const vmScript = await ofetch<string>(`https://static.zhihu.com/zse-ck/v4/${hash}.js`, {
headers,
parseResponse: (text) => text,
});
const dom = new JSDOM(`<!doctype html><html><head><meta id="zh-zse-ck" content="${meta}"><script data-assets-tracker-config='{"appName":"zse_ck"}'></script></head><body></body></html>`, {
url,
referrer: 'https://www.zhihu.com/',
runScripts: 'outside-only',
pretendToBeVisual: true,
virtualConsole: new VirtualConsole(),
});
const { window } = dom;
Object.defineProperties(window.navigator, {
userAgent: { value: ua, configurable: true },
webdriver: { value: false, configurable: true },
});
window.TextEncoder = TextEncoder;
window.TextDecoder = TextDecoder as typeof window.TextDecoder;
window.atob = (value: string) => Buffer.from(value, 'base64').toString('binary');
window.btoa = (value: string) => Buffer.from(value, 'binary').toString('base64');
Object.assign(window, { __g: {} });
const cookieDescriptor = Object.getOwnPropertyDescriptor(window.Document.prototype, 'cookie');
if (!cookieDescriptor?.get || !cookieDescriptor.set) {
window.close();
throw new Error('zhihu: JSDOM did not provide document.cookie accessors');
}
const tokenPromise = new Promise<string>((resolve) => {
Object.defineProperty(window.document, 'cookie', {
configurable: true,
get: cookieDescriptor.get,
set(value: string) {
Reflect.apply(cookieDescriptor.set, window.document, [value]);
const token = value.match(/__zse_ck=([^;]+)/)?.[1];
if (token?.includes('-')) {
resolve(token);
}
},
});
});
// calculate x-zse-96, refer to https://github.com/srx-2000/spider_collection/issues/18
let zseCk: string | undefined;
try {
// Zhihu's challenge is intentionally delivered as executable JavaScript.
new Script(vmScript).runInContext(dom.getInternalVMContext());
zseCk = (await Promise.race([tokenPromise, wait(3000)])) as string | undefined;
} finally {
window.close();
}
if (!zseCk) {
throw new Error('zhihu: WASM VM did not produce a __zse_ck');
}
return { dc0, zseCk, ua };
};
const getGeneratedZseCredentials = (url: string, apiPath: string, configuredDc0: string) => {
const cacheKey = `zhihu:zse-ck:v4:${configuredDc0 ? md5(configuredDc0) : 'guest'}`;
const pending = pendingZseCredentials.get(cacheKey);
if (pending) {
return pending;
}
const created = (async () => {
try {
return await cache.tryGet(cacheKey, () => generateZseCk(url, apiPath, configuredDc0), config.cache.contentExpire, false);
} finally {
pendingZseCredentials.delete(cacheKey);
}
})();
pendingZseCredentials.set(cacheKey, created);
return created;
};
const mergeGeneratedCookies = (configured: string, dc0: string, zseCk: string) => {
const remaining = configured
.split(';')
.map((pair) => pair.trim())
.filter((pair) => {
const name = pair.split('=', 1)[0];
return name && name !== 'd_c0' && name !== '__zse_ck';
});
return [`__zse_ck=${zseCk}`, `d_c0=${dc0}`, ...remaining].join('; ');
};
export const getSignedHeader = async (url: string, apiPath: string) => {
const configured = (config?.zhihu?.cookies as string | undefined) || '';
const configuredDc0 = getCookieValueFrom(configured, 'd_c0');
const configuredZseCk = getCookieValueFrom(configured, '__zse_ck');
// A configured pair may have been generated with a different user-agent, so
// preserve the previous behavior and trust it as-is. Generated credentials
// always return their matching user-agent.
let cookieStr: string;
let ua: string | undefined;
if (configuredDc0 && configuredZseCk) {
cookieStr = configured;
} else {
const credentials = await getGeneratedZseCredentials(url, apiPath, configuredDc0);
// Login cookies only belong to the configured d_c0 session. Do not mix
// an isolated z_c0 with a newly-created guest session.
cookieStr = configuredDc0 ? mergeGeneratedCookies(configured, credentials.dc0, credentials.zseCk) : `__zse_ck=${credentials.zseCk}; d_c0=${credentials.dc0}`;
ua = credentials.ua;
}
// Sign with the same `d_c0` that is sent, otherwise the backend rejects the
// request. Refer to https://github.com/srx-2000/spider_collection/issues/18
const dc0 = getCookieValueFrom(cookieStr, 'd_c0');
const xzse93 = '101_3_3.0';
const f = `${xzse93}+${apiPath}+${dc0}`;
const xzse96 = '2.0_' + g_encrypt(md5(f));
const zc0 = getCookieValueByKey('z_c0');
return {
cookie: `__zse_ck=${zseCk}; d_c0=${dc0}${zc0 ? `;z_c0=${zc0}` : ''}`,
cookie: cookieStr,
...(ua && { 'user-agent': ua }),
'x-zse-96': xzse96,
'x-app-za': 'OS=Web',
'x-zse-93': xzse93,