Merge PR #48: feat: add Xiaohongshu source + Reddit public fallback
- Xiaohongshu search via local MCP service (opt-in, zero impact if service not running) - Reddit public JSON fallback (works with zero API keys) - Reddit priority: ScrapeCreators -> OpenAI -> public fallback - Updated env.py: Reddit always available via public fallback Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+218
@@ -0,0 +1,218 @@
|
||||
import { TWITTER_API_BASE, TWITTER_GRAPHQL_POST_URL, TWITTER_STATUS_UPDATE_URL } from './twitter-client-constants.js';
|
||||
import { buildTweetCreateFeatures } from './twitter-client-features.js';
|
||||
export function withPosting(Base) {
|
||||
class TwitterClientPosting extends Base {
|
||||
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
}
|
||||
/**
|
||||
* Post a new tweet
|
||||
*/
|
||||
async tweet(text, mediaIds) {
|
||||
const variables = {
|
||||
tweet_text: text,
|
||||
dark_request: false,
|
||||
media: {
|
||||
media_entities: (mediaIds ?? []).map((id) => ({ media_id: id, tagged_users: [] })),
|
||||
possibly_sensitive: false,
|
||||
},
|
||||
semantic_annotation_ids: [],
|
||||
};
|
||||
const features = buildTweetCreateFeatures();
|
||||
return this.createTweet(variables, features);
|
||||
}
|
||||
/**
|
||||
* Reply to an existing tweet
|
||||
*/
|
||||
async reply(text, replyToTweetId, mediaIds) {
|
||||
const variables = {
|
||||
tweet_text: text,
|
||||
reply: {
|
||||
in_reply_to_tweet_id: replyToTweetId,
|
||||
exclude_reply_user_ids: [],
|
||||
},
|
||||
dark_request: false,
|
||||
media: {
|
||||
media_entities: (mediaIds ?? []).map((id) => ({ media_id: id, tagged_users: [] })),
|
||||
possibly_sensitive: false,
|
||||
},
|
||||
semantic_annotation_ids: [],
|
||||
};
|
||||
const features = buildTweetCreateFeatures();
|
||||
return this.createTweet(variables, features);
|
||||
}
|
||||
async createTweet(variables, features) {
|
||||
await this.ensureClientUserId();
|
||||
let queryId = await this.getQueryId('CreateTweet');
|
||||
let urlWithOperation = `${TWITTER_API_BASE}/${queryId}/CreateTweet`;
|
||||
const buildBody = () => JSON.stringify({ variables, features, queryId });
|
||||
let body = buildBody();
|
||||
try {
|
||||
const headers = { ...this.getHeaders(), referer: 'https://x.com/compose/post' };
|
||||
let response = await this.fetchWithTimeout(urlWithOperation, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
// Twitter increasingly prefers POST to /i/api/graphql with queryId in the payload.
|
||||
// If the operation URL 404s, retry the generic endpoint.
|
||||
if (response.status === 404) {
|
||||
await this.refreshQueryIds();
|
||||
queryId = await this.getQueryId('CreateTweet');
|
||||
urlWithOperation = `${TWITTER_API_BASE}/${queryId}/CreateTweet`;
|
||||
body = buildBody();
|
||||
response = await this.fetchWithTimeout(urlWithOperation, {
|
||||
method: 'POST',
|
||||
headers: { ...this.getHeaders(), referer: 'https://x.com/compose/post' },
|
||||
body,
|
||||
});
|
||||
if (response.status === 404) {
|
||||
const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, {
|
||||
method: 'POST',
|
||||
headers: { ...this.getHeaders(), referer: 'https://x.com/compose/post' },
|
||||
body,
|
||||
});
|
||||
if (!retry.ok) {
|
||||
const text = await retry.text();
|
||||
return { success: false, error: `HTTP ${retry.status}: ${text.slice(0, 200)}` };
|
||||
}
|
||||
const data = (await retry.json());
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
const fallback = await this.tryStatusUpdateFallback(data.errors, variables);
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
}
|
||||
return { success: false, error: this.formatErrors(data.errors) };
|
||||
}
|
||||
const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id;
|
||||
if (tweetId) {
|
||||
return { success: true, tweetId };
|
||||
}
|
||||
return { success: false, error: 'Tweet created but no ID returned' };
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
return {
|
||||
success: false,
|
||||
error: `HTTP ${response.status}: ${text.slice(0, 200)}`,
|
||||
};
|
||||
}
|
||||
const data = (await response.json());
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
const fallback = await this.tryStatusUpdateFallback(data.errors, variables);
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: this.formatErrors(data.errors),
|
||||
};
|
||||
}
|
||||
const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id;
|
||||
if (tweetId) {
|
||||
return {
|
||||
success: true,
|
||||
tweetId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: 'Tweet created but no ID returned',
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
formatErrors(errors) {
|
||||
return errors
|
||||
.map((error) => (typeof error.code === 'number' ? `${error.message} (${error.code})` : error.message))
|
||||
.join(', ');
|
||||
}
|
||||
statusUpdateInputFromCreateTweetVariables(variables) {
|
||||
const text = typeof variables.tweet_text === 'string' ? variables.tweet_text : null;
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const reply = variables.reply;
|
||||
const inReplyToTweetId = reply &&
|
||||
typeof reply === 'object' &&
|
||||
typeof reply.in_reply_to_tweet_id === 'string'
|
||||
? reply.in_reply_to_tweet_id
|
||||
: undefined;
|
||||
const media = variables.media;
|
||||
const mediaEntities = media && typeof media === 'object' ? media.media_entities : undefined;
|
||||
const mediaIds = Array.isArray(mediaEntities)
|
||||
? mediaEntities
|
||||
.map((entity) => entity && typeof entity === 'object' && 'media_id' in entity
|
||||
? entity.media_id
|
||||
: undefined)
|
||||
.filter((value) => typeof value === 'string' || typeof value === 'number')
|
||||
.map((value) => String(value))
|
||||
: undefined;
|
||||
return { text, inReplyToTweetId, mediaIds: mediaIds && mediaIds.length > 0 ? mediaIds : undefined };
|
||||
}
|
||||
async postStatusUpdate(input) {
|
||||
const params = new URLSearchParams();
|
||||
params.set('status', input.text);
|
||||
if (input.inReplyToTweetId) {
|
||||
params.set('in_reply_to_status_id', input.inReplyToTweetId);
|
||||
params.set('auto_populate_reply_metadata', 'true');
|
||||
}
|
||||
if (input.mediaIds && input.mediaIds.length > 0) {
|
||||
params.set('media_ids', input.mediaIds.join(','));
|
||||
}
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(TWITTER_STATUS_UPDATE_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...this.getBaseHeaders(),
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
referer: 'https://x.com/compose/post',
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` };
|
||||
}
|
||||
const data = (await response.json());
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
return { success: false, error: this.formatErrors(data.errors) };
|
||||
}
|
||||
const tweetId = typeof data.id_str === 'string' ? data.id_str : data.id !== undefined ? String(data.id) : undefined;
|
||||
if (tweetId) {
|
||||
return { success: true, tweetId };
|
||||
}
|
||||
return { success: false, error: 'Tweet created but no ID returned' };
|
||||
}
|
||||
catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
async tryStatusUpdateFallback(errors, variables) {
|
||||
if (!errors.some((error) => error.code === 226)) {
|
||||
return null;
|
||||
}
|
||||
const input = this.statusUpdateInputFromCreateTweetVariables(variables);
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
const fallback = await this.postStatusUpdate(input);
|
||||
if (fallback.success) {
|
||||
return fallback;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: `${this.formatErrors(errors)} | fallback: ${fallback.error ?? 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return TwitterClientPosting;
|
||||
}
|
||||
//# sourceMappingURL=twitter-client-posting.js.map
|
||||
Reference in New Issue
Block a user