-
Notifications
You must be signed in to change notification settings - Fork 7.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(route/qq): add 腾讯频道 (/qq/pd) route (#17316)
* feat(route/qq): add qq/pd route * chore(format): remove `any` type hints * chore(types): add types for qq/pd * fix(route): fix route example of /qq/pd/guild
- Loading branch information
Showing
3 changed files
with
347 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,146 @@ | ||
import { Data, DataItem, Route } from '@/types'; | ||
import ofetch from '@/utils/ofetch'; | ||
import InvalidParameterError from '@/errors/types/invalid-parameter'; | ||
import type { Context } from 'hono'; | ||
|
||
import { Feed } from './types'; | ||
import { parseFeed } from './utils'; | ||
import cache from '@/utils/cache'; | ||
|
||
const baseUrl = 'https://pd.qq.com/g/'; | ||
const baseApiUrl = 'https://pd.qq.com/qunng/guild/gotrpc/noauth/trpc.qchannel.commreader.ComReader/'; | ||
const getGuildFeedsUrl = baseApiUrl + 'GetGuildFeeds'; | ||
const getChannelTimelineFeedsUrl = baseApiUrl + 'GetChannelTimelineFeeds'; | ||
const getFeedDetailUrl = baseApiUrl + 'GetFeedDetail'; | ||
|
||
const sortMap = { | ||
hot: 0, | ||
created: 1, | ||
replied: 2, | ||
}; | ||
|
||
export const route: Route = { | ||
path: ['/pd/guild/:id/:sub?/:sort?'], | ||
categories: ['bbs'], | ||
example: '/qq/pd/guild/qrp4pkq01d/650967831/created', | ||
parameters: { | ||
id: '频道号', | ||
sub: '子频道 ID,网页端 URL `subc` 参数的值,默认为 `hot`(全部)', | ||
sort: '排序方式,`hot`(热门),`created`(最新发布),`replied`(最新回复),默认为 `created`', | ||
}, | ||
features: { | ||
requireConfig: false, | ||
requirePuppeteer: false, | ||
antiCrawler: false, | ||
supportBT: false, | ||
supportPodcast: false, | ||
supportScihub: false, | ||
}, | ||
radar: [ | ||
{ | ||
source: ['pd.qq.com/'], | ||
}, | ||
], | ||
name: '腾讯频道', | ||
maintainers: ['mobyw'], | ||
handler, | ||
url: 'pd.qq.com/', | ||
}; | ||
|
||
async function handler(ctx: Context): Promise<Data> { | ||
const { id, sub = 'hot', sort = 'created' } = ctx.req.param(); | ||
|
||
if (sort in sortMap === false) { | ||
throw new InvalidParameterError('invalid sort parameter, should be `hot`, `created`, or `replied`'); | ||
} | ||
const sortType = sortMap[sort]; | ||
|
||
let url = ''; | ||
let body = {}; | ||
let headers = {}; | ||
|
||
if (sub === 'hot') { | ||
url = getGuildFeedsUrl; | ||
// notice: do not change the order of the keys in the body | ||
body = { count: 20, from: 7, guild_number: id, get_type: 1, feedAttchInfo: '', sortOption: sortType, need_channel_list: false, need_top_info: false }; | ||
headers = { | ||
cookie: 'p_uin=o09000002', | ||
'x-oidb': '{"uint32_service_type":12}', | ||
'x-qq-client-appid': '537246381', | ||
}; | ||
} else { | ||
url = getChannelTimelineFeedsUrl; | ||
// notice: do not change the order of the keys in the body | ||
body = { count: 20, from: 7, guild_number: id, channelSign: { channel_id: sub }, feedAttchInfo: '', sortOption: sortType, need_top_info: false }; | ||
headers = { | ||
cookie: 'p_uin=o09000002', | ||
'x-oidb': '{"uint32_service_type":11}', | ||
'x-qq-client-appid': '537246381', | ||
}; | ||
} | ||
|
||
const data = await ofetch(url, { method: 'POST', body, headers }); | ||
const feeds = data.data?.vecFeed || []; | ||
|
||
const items = feeds.map(async (feed: Feed) => { | ||
let subId = sub; | ||
if (sub === 'hot') { | ||
// get real subId for hot feeds | ||
subId = feed.channelInfo?.sign?.channel_id || ''; | ||
} | ||
const feedLink = baseUrl + id + '/post/' + feed.id; | ||
const feedDetail = await cache.tryGet(feedLink, async () => { | ||
// notice: do not change the order of the keys in the body | ||
body = { | ||
feedId: feed.id, | ||
userId: feed.poster?.id, | ||
createTime: feed.createTime, | ||
from: 2, | ||
detail_type: 1, | ||
channelSign: { guild_number: id, channel_id: subId }, | ||
extInfo: { | ||
mapInfo: [ | ||
{ key: 'qc-tabid', value: 'ark' }, | ||
{ key: 'qc-pageid', value: 'pc' }, | ||
], | ||
}, | ||
}; | ||
headers = { | ||
cookie: 'p_uin=o09000002', | ||
referer: feedLink, | ||
'x-oidb': '{"uint32_service_type":5}', | ||
'x-qq-client-appid': '537246381', | ||
}; | ||
const feedResponse = await ofetch(getFeedDetailUrl, { method: 'POST', body, headers }); | ||
const feedContent: Feed = feedResponse.data?.feed || {}; | ||
return { | ||
title: feed.title?.contents[0]?.text_content?.text || feed.channelInfo?.guild_name || '', | ||
link: feedLink, | ||
description: parseFeed(feedContent), | ||
pubDate: new Date(Number(feed.createTime) * 1000), | ||
author: feed.poster?.nick, | ||
}; | ||
}); | ||
|
||
return feedDetail; | ||
}); | ||
|
||
const feedItems = await Promise.all(items); | ||
|
||
let guildName = ''; | ||
|
||
if (feeds.length > 0 && feeds[0].channelInfo?.guild_name) { | ||
guildName = feeds[0].channelInfo?.guild_name; | ||
if (sub !== 'hot' && feeds[0].channelInfo?.name) { | ||
guildName += ' (' + feeds[0].channelInfo?.name + ')'; | ||
} | ||
guildName += ' - 腾讯频道'; | ||
} | ||
|
||
return { | ||
title: guildName, | ||
link: baseUrl + id, | ||
description: guildName, | ||
item: feedItems as DataItem[], | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
// Description: Types for QQ PD API. | ||
|
||
export type Feed = { | ||
id: string; | ||
feed_type: number; // 1-post, 2-article | ||
patternInfo: string; // JSON string | ||
channelInfo: ChannelInfo; | ||
title: { | ||
contents: FeedContent[]; | ||
}; | ||
contents: { | ||
contents: FeedContent[]; | ||
}; | ||
images: FeedImage[]; | ||
poster: { | ||
id: string; | ||
nick: string; | ||
}; | ||
createTime: string; | ||
}; | ||
|
||
export type ChannelInfo = { | ||
name: string; | ||
guild_number: string; | ||
guild_name: string; | ||
sign: { | ||
guild_id: string; | ||
channel_id: string; | ||
}; | ||
}; | ||
|
||
export type FeedContent = { | ||
type: number; | ||
pattern_id: string; | ||
text_content?: { | ||
text: string; | ||
}; | ||
emoji_content?: { | ||
id: string; | ||
type: string; | ||
}; | ||
url_content?: { | ||
url: string; | ||
displayText: string; | ||
type: number; | ||
}; | ||
}; | ||
|
||
export type FeedImage = { | ||
picId: string; | ||
picUrl: string; | ||
width: number; | ||
height: number; | ||
pattern_id?: string; | ||
}; | ||
|
||
export type FeedPattern = { | ||
id: string; | ||
props?: { | ||
textAlignment: number; // 0-left, 1-center, 2-right | ||
}; | ||
data: FeedPatternData[]; | ||
}; | ||
|
||
export type FeedPatternData = { | ||
type: number; // 1-text, 2-emoji, 5-link, 6-image, 9-newline | ||
text?: string; | ||
props?: FeedFontProps; | ||
fileId?: string; | ||
taskId?: string; | ||
url?: string; | ||
width?: number; | ||
height?: number; | ||
desc?: string; | ||
href?: string; | ||
}; | ||
|
||
export type FeedFontProps = { | ||
fontWeight: number; // 400-normal, 700-bold | ||
italic: boolean; | ||
underline: boolean; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,119 @@ | ||
// Description: QQ PD utils | ||
|
||
import { Feed, FeedImage, FeedPattern, FeedFontProps, FeedPatternData } from './types'; | ||
|
||
const patternTypeMap = { | ||
1: 'text', | ||
2: 'emoji', | ||
5: 'link', | ||
6: 'image', | ||
9: 'newline', | ||
}; | ||
|
||
const textAlignmentMap = { | ||
0: 'left', | ||
1: 'center', | ||
2: 'right', | ||
}; | ||
|
||
function parseText(text: string, props: FeedFontProps | undefined): string { | ||
if (props === undefined) { | ||
return text; | ||
} | ||
let style = ''; | ||
if (props.fontWeight === 700) { | ||
style += 'font-weight: bold;'; | ||
} | ||
if (props.italic) { | ||
style += 'font-style: italic;'; | ||
} | ||
if (props.underline) { | ||
style += 'text-decoration: underline;'; | ||
} | ||
if (style === '') { | ||
return text; | ||
} | ||
return `<span style="${style}">${text}</span>`; | ||
} | ||
|
||
function parseDataItem(item: FeedPatternData, texts: string[], images: { [id: string]: FeedImage }): string { | ||
let imageId = ''; | ||
switch (patternTypeMap[item.type] || undefined) { | ||
case 'text': | ||
return parseText(texts.shift() ?? '', item.props); | ||
case 'newline': | ||
texts.shift(); | ||
return '<br />'; | ||
case 'link': | ||
return `<a href="${item.href ?? '#'}" target="_blank">${item.desc ?? ''}</a>`; | ||
case 'image': | ||
imageId = item.fileId || item.taskId || ''; | ||
return `<img src="${images[imageId].picUrl}" style="max-width: 100%; width: ${images[imageId].width}px;"><br />`; | ||
default: | ||
return ''; | ||
} | ||
} | ||
|
||
function parseArticle(feed: Feed, texts: string[], images: { [id: string]: FeedImage }): string { | ||
let result = ''; | ||
if (feed.patternInfo === undefined || feed.patternInfo === null || feed.patternInfo === '') { | ||
feed.patternInfo = '[]'; | ||
} | ||
const patterns: FeedPattern[] = JSON.parse(feed.patternInfo); | ||
for (const pattern of patterns) { | ||
if (pattern.props === undefined) { | ||
continue; | ||
} | ||
const textAlign = pattern.props.textAlignment || 0; | ||
result += '<p style="text-align: ' + textAlignmentMap[textAlign] + ';">'; | ||
for (const item of pattern.data) { | ||
result += parseDataItem(item, texts, images); | ||
} | ||
result += '</p>'; | ||
} | ||
return result; | ||
} | ||
|
||
function parsePost(feed: Feed, texts: string[], images: { [id: string]: FeedImage }): string { | ||
for (const content of feed.contents.contents) { | ||
if (content.text_content) { | ||
texts.push(content.text_content.text); | ||
} | ||
} | ||
let result = ''; | ||
for (const text of texts) { | ||
result += text; | ||
} | ||
for (const image of Object.values(images)) { | ||
result += '<p style="text-align: center">'; | ||
result += `<img src="${image.picUrl}" style="max-width: 100%; width: ${image.width}px;">`; | ||
result += '</p>'; | ||
} | ||
return result; | ||
} | ||
|
||
export function parseFeed(feed: Feed): string { | ||
const texts: string[] = []; | ||
const images: { [id: string]: FeedImage } = {}; | ||
for (const content of feed.contents.contents) { | ||
if (content.text_content) { | ||
texts.push(content.text_content.text); | ||
} | ||
} | ||
for (const image of feed.images) { | ||
images[image.picId] = { | ||
picId: image.picId, | ||
picUrl: image.picUrl, | ||
width: image.width, | ||
height: image.height, | ||
}; | ||
} | ||
if (feed.feed_type === 1) { | ||
// post: text and attachments | ||
return parsePost(feed, texts, images); | ||
} else if (feed.feed_type === 2) { | ||
// article: pattern info | ||
return parseArticle(feed, texts, images); | ||
} | ||
return ''; | ||
} |