feat(desktop): implement workspace foundation + desktop-client skeleton
Plan 0 (workspaces): add workspaces + workspace_memberships schema, extend JWT/Actor/claims with primary_workspace_id, seed default workspace per tenant, thread workspace_id through tenant monitoring quota. Plan A (desktop skeleton): new Electron app (apps/desktop-client) with main/ preload/renderer, shared Vue component package (packages/ui-shared), and server surface — desktop client registration + token rotation + heartbeat, SSE task event stream, desktop accounts/tasks/content handlers, publish job endpoint, and supporting repositories, services, sqlc queries, and migrations. Hard cutover per plan: remove browser-extension monitoring callback endpoints, stub legacy media API in admin-web, and delete monitoring_callback_handler.go.
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import type { DesktopArticleContent, JsonValue } from "@geo/shared-types";
|
||||
import type { Session, WebContentsView } from "electron";
|
||||
|
||||
export type AdapterTaskPhase = "initial" | "resume";
|
||||
export type AdapterCompletionStatus = "succeeded" | "failed" | "unknown";
|
||||
|
||||
export interface AdapterContext {
|
||||
taskId: string;
|
||||
accountId: string;
|
||||
session: Session;
|
||||
view: WebContentsView;
|
||||
signal: AbortSignal;
|
||||
mode: "auto" | "manual";
|
||||
phase: AdapterTaskPhase;
|
||||
reportProgress(stage: string): void;
|
||||
}
|
||||
|
||||
export interface PublishAdapterContext extends AdapterContext {
|
||||
article: DesktopArticleContent & {
|
||||
publish_type: "publish" | "draft";
|
||||
};
|
||||
}
|
||||
|
||||
export interface AdapterExecutionResult {
|
||||
status: AdapterCompletionStatus;
|
||||
summary: string;
|
||||
payload?: Record<string, JsonValue>;
|
||||
error?: Record<string, JsonValue>;
|
||||
reviewUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface PublishAdapter {
|
||||
platform: string;
|
||||
publish(context: PublishAdapterContext, payload: Record<string, unknown>): Promise<AdapterExecutionResult>;
|
||||
}
|
||||
|
||||
export interface MonitorAdapter {
|
||||
provider: string;
|
||||
query(context: AdapterContext, payload: Record<string, unknown>): Promise<AdapterExecutionResult>;
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import type { DesktopArticleContent } from "@geo/shared-types";
|
||||
import type { Session, WebContents, WebContentsView } from "electron/main";
|
||||
|
||||
import { STANDARD_ACCEPT_LANGUAGES, STANDARD_USER_AGENT } from "../user-agent";
|
||||
|
||||
export function normalizeText(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
export function normalizeRemoteUrl(value?: string | null): string | null {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (/^(data|blob):/i.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
if (/^\/\//.test(trimmed)) {
|
||||
return `https:${trimmed}`;
|
||||
}
|
||||
if (/^[a-z][a-z\d+\-.]*:/i.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
return `https://${trimmed.replace(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
export function normalizeArticleHtml(article: DesktopArticleContent): string {
|
||||
let next = article.html_content?.trim() ?? "";
|
||||
next = next.replace(/<section\b/gi, "<div").replace(/<\/section>/gi, "</div>");
|
||||
next = next.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, "");
|
||||
next = next.replace(/\sstyle="[^"]*"/gi, "");
|
||||
next = next.replace(/\sdata-[a-z-]+="[^"]*"/gi, "");
|
||||
next = next.replace(/<figure[^>]*>\s*(<img[\s\S]*?>)\s*<\/figure>/gi, "$1");
|
||||
return next.trim();
|
||||
}
|
||||
|
||||
export function extractImageSources(html: string): string[] {
|
||||
const sources = new Set<string>();
|
||||
for (const match of html.matchAll(/<img\b[^>]*src=(['"])(.*?)\1[^>]*>/gi)) {
|
||||
const src = match[2]?.trim();
|
||||
if (src) {
|
||||
sources.add(src);
|
||||
}
|
||||
}
|
||||
return [...sources];
|
||||
}
|
||||
|
||||
export async function fetchImageBlob(sourceUrl: string): Promise<Blob | null> {
|
||||
const normalizedUrl = normalizeRemoteUrl(sourceUrl);
|
||||
if (!normalizedUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetch(normalizedUrl).catch(() => null);
|
||||
if (!response?.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const blob = await response.blob().catch(() => null);
|
||||
if (!blob || !blob.type.startsWith("image/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return blob;
|
||||
}
|
||||
|
||||
export async function uploadHtmlImages(
|
||||
html: string,
|
||||
uploader: (sourceUrl: string) => Promise<string | null>,
|
||||
): Promise<{ html: string; uploaded: Map<string, string> }> {
|
||||
const sources = extractImageSources(html);
|
||||
if (!sources.length) {
|
||||
return {
|
||||
html,
|
||||
uploaded: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
const uploaded = new Map<string, string>();
|
||||
for (const source of sources) {
|
||||
const target = await uploader(source);
|
||||
if (target) {
|
||||
uploaded.set(source, target);
|
||||
}
|
||||
}
|
||||
|
||||
let next = html;
|
||||
for (const [from, to] of uploaded.entries()) {
|
||||
next = next.split(from).join(to);
|
||||
}
|
||||
|
||||
return { html: next, uploaded };
|
||||
}
|
||||
|
||||
export async function sessionFetchText(
|
||||
session: Session,
|
||||
input: string,
|
||||
init?: RequestInit,
|
||||
): Promise<string> {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (!headers.has("user-agent")) {
|
||||
headers.set("user-agent", STANDARD_USER_AGENT);
|
||||
}
|
||||
if (!headers.has("accept-language")) {
|
||||
headers.set("accept-language", STANDARD_ACCEPT_LANGUAGES);
|
||||
}
|
||||
|
||||
const response = await session.fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(text || `request_failed_${response.status}`);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export async function sessionFetchJson<T>(
|
||||
session: Session,
|
||||
input: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const text = await sessionFetchText(session, input, init);
|
||||
if (!text) {
|
||||
return {} as T;
|
||||
}
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
export interface PageFetchInit {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
credentials?: "include" | "same-origin" | "omit";
|
||||
}
|
||||
|
||||
export async function sessionCookieFetchJson<T>(
|
||||
session: Session,
|
||||
url: string,
|
||||
init?: { method?: string; headers?: Record<string, string>; body?: string },
|
||||
): Promise<T | null> {
|
||||
let parsedURL: URL;
|
||||
try {
|
||||
parsedURL = new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cookieHeader = "";
|
||||
try {
|
||||
const cookies = await session.cookies.get({ url });
|
||||
cookieHeader = cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
|
||||
} catch {
|
||||
cookieHeader = "";
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"user-agent": STANDARD_USER_AGENT,
|
||||
"accept-language": STANDARD_ACCEPT_LANGUAGES,
|
||||
accept: "application/json, text/plain, */*",
|
||||
referer: `${parsedURL.protocol}//${parsedURL.host}/`,
|
||||
origin: `${parsedURL.protocol}//${parsedURL.host}`,
|
||||
};
|
||||
if (cookieHeader) {
|
||||
headers.cookie = cookieHeader;
|
||||
}
|
||||
if (init?.headers) {
|
||||
for (const [key, value] of Object.entries(init.headers)) {
|
||||
headers[key.toLowerCase()] = value;
|
||||
}
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: init?.method ?? "GET",
|
||||
headers,
|
||||
body: init?.body,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let text: string;
|
||||
try {
|
||||
text = await response.text();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
return {} as T;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function pageFetchJson<T>(
|
||||
webContents: WebContents,
|
||||
url: string,
|
||||
init?: PageFetchInit,
|
||||
): Promise<T | null> {
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectiveInit: PageFetchInit = {
|
||||
credentials: "include",
|
||||
...init,
|
||||
};
|
||||
|
||||
const script = `(async () => {
|
||||
try {
|
||||
const response = await fetch(${JSON.stringify(url)}, ${JSON.stringify(effectiveInit)});
|
||||
if (!response.ok) {
|
||||
return JSON.stringify({ __ok: false, status: response.status });
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return JSON.stringify({ __ok: true, empty: true });
|
||||
}
|
||||
return JSON.stringify({ __ok: true, body: text });
|
||||
} catch (error) {
|
||||
return JSON.stringify({ __ok: false, error: String(error && error.message || error) });
|
||||
}
|
||||
})()`;
|
||||
|
||||
let serialized: string;
|
||||
try {
|
||||
serialized = (await webContents.executeJavaScript(script, true)) as string;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof serialized !== "string" || !serialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let envelope: { __ok: boolean; body?: string; empty?: boolean; status?: number; error?: string };
|
||||
try {
|
||||
envelope = JSON.parse(serialized);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!envelope.__ok) {
|
||||
return null;
|
||||
}
|
||||
if (envelope.empty) {
|
||||
return {} as T;
|
||||
}
|
||||
if (!envelope.body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(envelope.body) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sessionCookieValue(
|
||||
session: Session,
|
||||
domain: string,
|
||||
name: string,
|
||||
): Promise<string> {
|
||||
const cookies = await session.cookies.get({ domain });
|
||||
return cookies.find((item) => item.name === name)?.value ?? "";
|
||||
}
|
||||
|
||||
export async function sessionCookieHeader(session: Session, domain: string): Promise<string> {
|
||||
const cookies = await session.cookies.get({ domain });
|
||||
return cookies.map((item) => `${item.name}=${item.value}`).join("; ");
|
||||
}
|
||||
|
||||
export async function ensureViewLoaded(
|
||||
view: WebContentsView,
|
||||
url: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
if (signal?.aborted) {
|
||||
throw new Error("adapter_aborted");
|
||||
}
|
||||
|
||||
if (view.webContents.isLoading()) {
|
||||
await waitForLoadStop(view, signal);
|
||||
}
|
||||
|
||||
const currentURL = view.webContents.getURL();
|
||||
if (currentURL === url) {
|
||||
return;
|
||||
}
|
||||
|
||||
await view.webContents.loadURL(url);
|
||||
await waitForLoadStop(view, signal);
|
||||
}
|
||||
|
||||
async function waitForLoadStop(view: WebContentsView, signal?: AbortSignal): Promise<void> {
|
||||
if (!view.webContents.isLoading()) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (view.webContents.isLoading()) {
|
||||
if (signal?.aborted) {
|
||||
throw new Error("adapter_aborted");
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 120);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,780 @@
|
||||
import type { JsonValue, MonitoringSourceItem } from "@geo/shared-types";
|
||||
|
||||
import { ensureViewLoaded, normalizeText } from "./common";
|
||||
import type { MonitorAdapter } from "./base";
|
||||
|
||||
const DOUBAO_APP_ID = "497858";
|
||||
const DOUBAO_BOT_ID = "7338286299411103781";
|
||||
const DOUBAO_CHAT_URL = "https://www.doubao.com/chat/completion";
|
||||
const DOUBAO_REFERER = "https://www.doubao.com/chat/";
|
||||
|
||||
const SEARCH_RESULT_KEYS = new Set([
|
||||
"references",
|
||||
"reference_list",
|
||||
"referenceList",
|
||||
"reference_cards",
|
||||
"referenceCards",
|
||||
"reference_docs",
|
||||
"referenceDocs",
|
||||
"sources",
|
||||
"source_list",
|
||||
"sourceList",
|
||||
"source_cards",
|
||||
"sourceCards",
|
||||
"search_results",
|
||||
"searchResults",
|
||||
"search_result_list",
|
||||
"searchResultList",
|
||||
"search_refs",
|
||||
"searchRefs",
|
||||
"web_results",
|
||||
"webResults",
|
||||
"grounding",
|
||||
"grounding_results",
|
||||
"groundingResults",
|
||||
"browse_results",
|
||||
"browseResults",
|
||||
"browse_references",
|
||||
"browseReferences",
|
||||
]);
|
||||
|
||||
const CONTENT_CITATION_KEYS = new Set([
|
||||
"citations",
|
||||
"citation_list",
|
||||
"citationList",
|
||||
"content_citations",
|
||||
"contentCitations",
|
||||
"answer_citations",
|
||||
"answerCitations",
|
||||
]);
|
||||
|
||||
type DoubaoRuntimeState = {
|
||||
fp: string;
|
||||
ms_token: string;
|
||||
device_id: string;
|
||||
web_id: string;
|
||||
tea_uuid: string;
|
||||
};
|
||||
|
||||
type DoubaoStreamEvent = {
|
||||
event: string;
|
||||
payload: unknown;
|
||||
};
|
||||
|
||||
type DoubaoStreamSummary = {
|
||||
answer: string | null;
|
||||
provider_request_id: string | null;
|
||||
request_id: string | null;
|
||||
conversation_id: string | null;
|
||||
citations: MonitoringSourceItem[];
|
||||
search_results: MonitoringSourceItem[];
|
||||
event_count: number;
|
||||
error_message: string | null;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function safeParseJSON(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function getDirectString(record: Record<string, unknown>, keys: string[]): string | null {
|
||||
for (const key of keys) {
|
||||
const value = normalizeOptionalString(record[key]);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findFirstStringByKey(source: unknown, keys: string[], depth = 0): string | null {
|
||||
if (depth > 8 || source == null) {
|
||||
return null;
|
||||
}
|
||||
if (Array.isArray(source)) {
|
||||
for (const item of source) {
|
||||
const found = findFirstStringByKey(item, keys, depth + 1);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(source)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const direct = normalizeOptionalString(source[key]);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
}
|
||||
|
||||
for (const value of Object.values(source)) {
|
||||
const found = findFirstStringByKey(value, keys, depth + 1);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readNestedString(source: unknown, path: string[]): string | null {
|
||||
let current: unknown = source;
|
||||
for (const key of path) {
|
||||
if (!isRecord(current) || !(key in current)) {
|
||||
return null;
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
return normalizeOptionalString(current);
|
||||
}
|
||||
|
||||
function mergeAnswerText(current: string | null, nextFragment: string | null): string | null {
|
||||
if (!nextFragment) {
|
||||
return current;
|
||||
}
|
||||
if (!current) {
|
||||
return nextFragment;
|
||||
}
|
||||
if (current === nextFragment || current.includes(nextFragment)) {
|
||||
return current;
|
||||
}
|
||||
if (nextFragment.startsWith(current)) {
|
||||
return nextFragment;
|
||||
}
|
||||
return `${current}${nextFragment}`;
|
||||
}
|
||||
|
||||
function resolveSourceBucket(key: string): "search" | "citation" | null {
|
||||
const normalized = key.replace(/[^a-z0-9]/gi, "").toLowerCase();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (CONTENT_CITATION_KEYS.has(key) || normalized.includes("citation")) {
|
||||
return "citation";
|
||||
}
|
||||
if (SEARCH_RESULT_KEYS.has(key) || normalized.includes("reference") || normalized.includes("source") || normalized.includes("search")) {
|
||||
return "search";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeSourceUrl(value: string | null): string | null {
|
||||
const input = normalizeText(value);
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(input);
|
||||
url.hash = "";
|
||||
return url.toString();
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
function buildSourceItem(input: unknown): MonitoringSourceItem | null {
|
||||
if (typeof input === "string") {
|
||||
const url = normalizeSourceUrl(input);
|
||||
return url ? { url, normalized_url: url } : null;
|
||||
}
|
||||
if (!isRecord(input)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = normalizeSourceUrl(
|
||||
getDirectString(input, [
|
||||
"url",
|
||||
"link",
|
||||
"uri",
|
||||
"href",
|
||||
"cited_url",
|
||||
"target_url",
|
||||
"targetUrl",
|
||||
"source_url",
|
||||
"sourceUrl",
|
||||
"page_url",
|
||||
"pageUrl",
|
||||
"jump_url",
|
||||
"jumpUrl",
|
||||
]),
|
||||
);
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let host: string | null = null;
|
||||
try {
|
||||
host = new URL(url).hostname || null;
|
||||
} catch {
|
||||
host = null;
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
title: getDirectString(input, ["title", "name", "cited_title", "page_title", "display_title", "page_name"]),
|
||||
site_name: getDirectString(input, ["site_name", "siteName", "source", "platform_name", "domain", "site", "host_name"]),
|
||||
site_key: getDirectString(input, ["site_key", "siteKey", "domain_key"]),
|
||||
normalized_url: url,
|
||||
host,
|
||||
resolution_status: getDirectString(input, ["resolution_status", "resolutionStatus"]),
|
||||
resolution_confidence: getDirectString(input, ["resolution_confidence", "resolutionConfidence"]),
|
||||
};
|
||||
}
|
||||
|
||||
function dedupeSourceItems(items: MonitoringSourceItem[]): MonitoringSourceItem[] {
|
||||
const keyed = new Map<string, MonitoringSourceItem>();
|
||||
for (const item of items) {
|
||||
const key = normalizeSourceUrl(item.normalized_url ?? item.url);
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = keyed.get(key);
|
||||
if (!existing) {
|
||||
keyed.set(key, {
|
||||
...item,
|
||||
normalized_url: key,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
keyed.set(key, {
|
||||
...existing,
|
||||
title: existing.title ?? item.title ?? null,
|
||||
site_name: existing.site_name ?? item.site_name ?? null,
|
||||
site_key: existing.site_key ?? item.site_key ?? null,
|
||||
host: existing.host ?? item.host ?? null,
|
||||
resolution_status: existing.resolution_status ?? item.resolution_status ?? null,
|
||||
resolution_confidence: existing.resolution_confidence ?? item.resolution_confidence ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(keyed.values());
|
||||
}
|
||||
|
||||
function collectSources(
|
||||
payload: unknown,
|
||||
searchResults: MonitoringSourceItem[],
|
||||
citations: MonitoringSourceItem[],
|
||||
bucket: "search" | "citation" | null = null,
|
||||
depth = 0,
|
||||
): void {
|
||||
if (depth > 12 || payload == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const push = (item: MonitoringSourceItem, targetBucket: "search" | "citation" | null) => {
|
||||
if (targetBucket === "citation") {
|
||||
citations.push(item);
|
||||
return;
|
||||
}
|
||||
searchResults.push(item);
|
||||
};
|
||||
|
||||
if (typeof payload === "string") {
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
const item = buildSourceItem(payload);
|
||||
if (item) {
|
||||
push(item, bucket);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
for (const item of payload) {
|
||||
collectSources(item, searchResults, citations, bucket, depth + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRecord(payload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const directSourceItem = buildSourceItem(payload);
|
||||
if (directSourceItem && bucket) {
|
||||
push(directSourceItem, bucket);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
const nextBucket = resolveSourceBucket(key) ?? bucket;
|
||||
if (Array.isArray(value) || isRecord(value) || typeof value === "string") {
|
||||
collectSources(value, searchResults, citations, nextBucket, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectAnswerFragments(payload: unknown, result: string[] = []): string[] {
|
||||
if (payload == null) {
|
||||
return result;
|
||||
}
|
||||
if (Array.isArray(payload)) {
|
||||
for (const item of payload) {
|
||||
collectAnswerFragments(item, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (!isRecord(payload)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const textBlockText = readNestedString(payload, ["text_block", "text"]);
|
||||
if (textBlockText) {
|
||||
result.push(textBlockText);
|
||||
}
|
||||
|
||||
const deltaText = normalizeOptionalString(payload.delta);
|
||||
if (deltaText) {
|
||||
result.push(deltaText);
|
||||
}
|
||||
|
||||
const nestedDeltaText = readNestedString(payload, ["delta", "text"]);
|
||||
if (nestedDeltaText) {
|
||||
result.push(nestedDeltaText);
|
||||
}
|
||||
|
||||
const assistantText =
|
||||
(normalizeOptionalString(payload.role) === "assistant" ||
|
||||
normalizeOptionalString(payload.message_type) === "assistant") &&
|
||||
normalizeOptionalString(payload.text);
|
||||
if (assistantText) {
|
||||
result.push(assistantText);
|
||||
}
|
||||
|
||||
for (const value of Object.values(payload)) {
|
||||
if (Array.isArray(value) || isRecord(value)) {
|
||||
collectAnswerFragments(value, result);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseSSERecord(record: string): DoubaoStreamEvent | null {
|
||||
const lines = record.split(/\r?\n/);
|
||||
let eventName = "message";
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line || line.startsWith(":")) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("event:")) {
|
||||
eventName = line.slice(6).trim() || eventName;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice(5).trimStart());
|
||||
}
|
||||
}
|
||||
|
||||
if (!dataLines.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventName,
|
||||
payload: safeParseJSON(dataLines.join("\n")),
|
||||
};
|
||||
}
|
||||
|
||||
function formatDoubaoStreamError(payload: unknown): string {
|
||||
const errorCode = findFirstStringByKey(payload, ["error_code", "code"]);
|
||||
const errorMessage = findFirstStringByKey(payload, ["error_msg", "message", "detail"]);
|
||||
const verifyScene = findFirstStringByKey(payload, ["verify_scene"]);
|
||||
|
||||
if (verifyScene) {
|
||||
return `doubao verification required (${verifyScene})`;
|
||||
}
|
||||
if (errorCode && errorMessage) {
|
||||
return `doubao stream error ${errorCode}: ${errorMessage}`;
|
||||
}
|
||||
if (errorCode) {
|
||||
return `doubao stream error ${errorCode}`;
|
||||
}
|
||||
if (errorMessage) {
|
||||
return `doubao stream error: ${errorMessage}`;
|
||||
}
|
||||
return "doubao stream error";
|
||||
}
|
||||
|
||||
async function parseDoubaoStream(response: Response): Promise<DoubaoStreamSummary> {
|
||||
const summary: DoubaoStreamSummary = {
|
||||
answer: null,
|
||||
provider_request_id: null,
|
||||
request_id: null,
|
||||
conversation_id: null,
|
||||
citations: [],
|
||||
search_results: [],
|
||||
event_count: 0,
|
||||
error_message: null,
|
||||
};
|
||||
|
||||
const body = response.body;
|
||||
if (!body) {
|
||||
throw new Error("doubao response stream missing");
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const reader = body.getReader();
|
||||
let buffer = "";
|
||||
|
||||
const consumeRecord = (rawRecord: string) => {
|
||||
const parsed = parseSSERecord(rawRecord);
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
summary.event_count += 1;
|
||||
if (parsed.event.toUpperCase().includes("ERROR")) {
|
||||
summary.error_message = formatDoubaoStreamError(parsed.payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!summary.conversation_id) {
|
||||
summary.conversation_id =
|
||||
readNestedString(parsed.payload, ["ack_client_meta", "conversation_id"]) ??
|
||||
readNestedString(parsed.payload, ["client_meta", "conversation_id"]) ??
|
||||
findFirstStringByKey(parsed.payload, ["conversation_id"]);
|
||||
}
|
||||
if (!summary.provider_request_id) {
|
||||
summary.provider_request_id = findFirstStringByKey(parsed.payload, ["log_id", "request_id", "req_id"]);
|
||||
}
|
||||
if (!summary.request_id) {
|
||||
summary.request_id = findFirstStringByKey(parsed.payload, ["message_id", "local_message_id", "reply_id"]);
|
||||
}
|
||||
|
||||
const fragments = collectAnswerFragments(parsed.payload);
|
||||
for (const fragment of fragments) {
|
||||
summary.answer = mergeAnswerText(summary.answer, fragment);
|
||||
}
|
||||
|
||||
collectSources(parsed.payload, summary.search_results, summary.citations);
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
buffer += decoder.decode();
|
||||
buffer = buffer.replace(/\r\n/g, "\n");
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
buffer = buffer.replace(/\r\n/g, "\n");
|
||||
|
||||
let separatorIndex = buffer.indexOf("\n\n");
|
||||
while (separatorIndex !== -1) {
|
||||
const rawRecord = buffer.slice(0, separatorIndex).trim();
|
||||
buffer = buffer.slice(separatorIndex + 2);
|
||||
if (rawRecord) {
|
||||
consumeRecord(rawRecord);
|
||||
}
|
||||
separatorIndex = buffer.indexOf("\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
const tail = buffer.trim();
|
||||
if (tail) {
|
||||
consumeRecord(tail);
|
||||
}
|
||||
|
||||
summary.citations = dedupeSourceItems(summary.citations);
|
||||
summary.search_results = dedupeSourceItems(summary.search_results);
|
||||
return summary;
|
||||
}
|
||||
|
||||
function buildDoubaoRequestUrl(runtimeState: DoubaoRuntimeState): string {
|
||||
const url = new URL(DOUBAO_CHAT_URL);
|
||||
url.searchParams.set("aid", DOUBAO_APP_ID);
|
||||
url.searchParams.set("device_platform", "web");
|
||||
url.searchParams.set("device_id", runtimeState.device_id);
|
||||
url.searchParams.set("web_id", runtimeState.web_id);
|
||||
url.searchParams.set("tea_uuid", runtimeState.tea_uuid);
|
||||
url.searchParams.set("msToken", runtimeState.ms_token);
|
||||
url.searchParams.set("fp", runtimeState.fp);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function buildDoubaoRequestBody(questionText: string, runtimeState: DoubaoRuntimeState): Record<string, unknown> {
|
||||
return {
|
||||
client_meta: {
|
||||
local_conversation_id: `local_${Date.now()}${Math.floor(Math.random() * 1_000_000)}`,
|
||||
conversation_id: "",
|
||||
bot_id: DOUBAO_BOT_ID,
|
||||
last_section_id: "",
|
||||
last_message_index: null,
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
local_message_id: crypto.randomUUID(),
|
||||
content_block: [
|
||||
{
|
||||
block_type: 10000,
|
||||
content: {
|
||||
text_block: {
|
||||
text: questionText,
|
||||
icon_url: "",
|
||||
icon_url_dark: "",
|
||||
summary: "",
|
||||
},
|
||||
pc_event_block: "",
|
||||
},
|
||||
block_id: crypto.randomUUID(),
|
||||
parent_id: "",
|
||||
meta_info: [],
|
||||
append_fields: [],
|
||||
},
|
||||
],
|
||||
message_status: 0,
|
||||
},
|
||||
],
|
||||
option: {
|
||||
need_deep_think: 1,
|
||||
need_create_conversation: true,
|
||||
conversation_init_option: {
|
||||
need_ack_conversation: true,
|
||||
},
|
||||
sse_recv_event_options: {
|
||||
support_chunk_delta: true,
|
||||
},
|
||||
},
|
||||
ext: {
|
||||
use_deep_think: "1",
|
||||
fp: runtimeState.fp,
|
||||
conversation_init_option: JSON.stringify({
|
||||
need_ack_conversation: true,
|
||||
}),
|
||||
commerce_credit_config_enable: "0",
|
||||
sub_conv_firstmet_type: "1",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseRuntimeState(value: unknown): DoubaoRuntimeState | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fp = normalizeOptionalString(value.fp);
|
||||
const msToken = normalizeOptionalString(value.ms_token);
|
||||
const deviceID = normalizeOptionalString(value.device_id);
|
||||
const webID = normalizeOptionalString(value.web_id);
|
||||
const teaUUID = normalizeOptionalString(value.tea_uuid);
|
||||
if (!fp || !msToken || !deviceID || !webID || !teaUUID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
fp,
|
||||
ms_token: msToken,
|
||||
device_id: deviceID,
|
||||
web_id: webID,
|
||||
tea_uuid: teaUUID,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadDoubaoRuntimeState(context: Parameters<MonitorAdapter["query"]>[0]): Promise<DoubaoRuntimeState> {
|
||||
context.reportProgress("doubao.bootstrap_view");
|
||||
await ensureViewLoaded(context.view, DOUBAO_REFERER, context.signal);
|
||||
|
||||
context.reportProgress("doubao.read_runtime_state");
|
||||
const state = await context.view.webContents.executeJavaScript(
|
||||
`(() => {
|
||||
try {
|
||||
const safeParse = (value) => {
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
const samanthaState = safeParse(window.localStorage.getItem("samantha_web_web_id"));
|
||||
const teaState = safeParse(window.localStorage.getItem("__tea_cache_tokens_497858"));
|
||||
const pick = (source, paths) => {
|
||||
for (const path of paths) {
|
||||
let current = source;
|
||||
let ok = true;
|
||||
for (const key of path) {
|
||||
if (!current || typeof current !== "object" || !(key in current)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
if (ok && typeof current === "string" && current.trim()) {
|
||||
return current.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const readCookie = (name) => {
|
||||
const segments = document.cookie.split(";");
|
||||
for (const segment of segments) {
|
||||
const [rawName, ...rest] = segment.trim().split("=");
|
||||
if (rawName === name) {
|
||||
const joined = rest.join("=").trim();
|
||||
return joined || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return {
|
||||
fp: readCookie("s_v_web_id"),
|
||||
ms_token: window.localStorage.getItem("xmst"),
|
||||
device_id: pick(samanthaState, [["web_id"], ["device_id"], ["deviceId"], ["id"]]),
|
||||
web_id: pick(teaState, [["web_id"], ["webId"], ["id"]]),
|
||||
tea_uuid: pick(teaState, [["web_id"], ["tea_uuid"], ["teaUuid"], ["user_unique_id"], ["uuid"]]),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : "runtime_state_read_failed",
|
||||
};
|
||||
}
|
||||
})();`,
|
||||
true,
|
||||
);
|
||||
|
||||
const runtimeState = parseRuntimeState(state);
|
||||
if (!runtimeState) {
|
||||
throw new Error("doubao_runtime_state_missing");
|
||||
}
|
||||
return runtimeState;
|
||||
}
|
||||
|
||||
function extractQuestionText(payload: Record<string, unknown>): string {
|
||||
const candidates = [
|
||||
payload.question_text,
|
||||
payload.query,
|
||||
payload.question,
|
||||
payload.prompt,
|
||||
payload.content,
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const text = normalizeOptionalString(candidate);
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("doubao_question_text_missing");
|
||||
}
|
||||
|
||||
function toJsonSources(items: MonitoringSourceItem[]): JsonValue[] {
|
||||
return items.map((item) => ({
|
||||
url: item.url,
|
||||
title: item.title ?? null,
|
||||
site_name: item.site_name ?? null,
|
||||
site_key: item.site_key ?? null,
|
||||
normalized_url: item.normalized_url ?? null,
|
||||
host: item.host ?? null,
|
||||
resolution_status: item.resolution_status ?? null,
|
||||
resolution_confidence: item.resolution_confidence ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildAdapterError(
|
||||
code: string,
|
||||
message: string,
|
||||
extras: Record<string, JsonValue> = {},
|
||||
): Record<string, JsonValue> {
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
...extras,
|
||||
};
|
||||
}
|
||||
|
||||
export const doubaoAdapter: MonitorAdapter = {
|
||||
provider: "doubao",
|
||||
async query(context, payload) {
|
||||
const questionText = extractQuestionText(payload);
|
||||
const runtimeState = await loadDoubaoRuntimeState(context);
|
||||
const requestBody = buildDoubaoRequestBody(questionText, runtimeState);
|
||||
|
||||
context.reportProgress("doubao.query");
|
||||
const response = await context.session.fetch(buildDoubaoRequestUrl(runtimeState), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
referer: DOUBAO_REFERER,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const bodyText = await response.text().catch(() => "");
|
||||
const detail = normalizeText(bodyText)?.slice(0, 240);
|
||||
return {
|
||||
status: "failed",
|
||||
summary: detail ? `豆包请求失败:${detail}` : `豆包请求失败(${response.status})。`,
|
||||
error: buildAdapterError(
|
||||
"doubao_request_failed",
|
||||
detail || `doubao_request_failed_${response.status}`,
|
||||
{ http_status: response.status },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
context.reportProgress("doubao.parse_stream");
|
||||
const streamSummary = await parseDoubaoStream(response);
|
||||
if (streamSummary.error_message) {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: streamSummary.error_message,
|
||||
error: buildAdapterError("doubao_stream_error", streamSummary.error_message),
|
||||
};
|
||||
}
|
||||
|
||||
if (!streamSummary.answer && !streamSummary.citations.length && !streamSummary.search_results.length) {
|
||||
return {
|
||||
status: "unknown",
|
||||
summary: "豆包返回为空,已回写 unknown 等待后续对账。",
|
||||
error: buildAdapterError("doubao_empty_response", "doubao returned no answer or sources"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "succeeded",
|
||||
summary: "豆包监控任务执行成功。",
|
||||
payload: {
|
||||
platform: "doubao",
|
||||
provider_model: "doubao-web-thinking",
|
||||
provider_request_id: streamSummary.provider_request_id,
|
||||
request_id: streamSummary.request_id ?? streamSummary.conversation_id,
|
||||
conversation_id: streamSummary.conversation_id,
|
||||
answer: streamSummary.answer,
|
||||
citation_count: streamSummary.citations.length,
|
||||
search_result_count: streamSummary.search_results.length,
|
||||
event_count: streamSummary.event_count,
|
||||
citations: toJsonSources(streamSummary.citations),
|
||||
search_results: toJsonSources(streamSummary.search_results),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./base";
|
||||
export * from "./doubao";
|
||||
export * from "./toutiao";
|
||||
@@ -0,0 +1,296 @@
|
||||
import type { JsonValue } from "@geo/shared-types";
|
||||
|
||||
import {
|
||||
fetchImageBlob,
|
||||
normalizeArticleHtml,
|
||||
sessionFetchJson,
|
||||
uploadHtmlImages,
|
||||
} from "./common";
|
||||
import type { PublishAdapter } from "./base";
|
||||
|
||||
type ToutiaoMediaInfoResponse = {
|
||||
data?: {
|
||||
user?: {
|
||||
id_str?: string;
|
||||
screen_name?: string;
|
||||
https_avatar_url?: string;
|
||||
};
|
||||
media?: {
|
||||
has_third_party_ad_permission?: boolean;
|
||||
has_toutiao_ad_permission?: boolean;
|
||||
};
|
||||
};
|
||||
code?: number;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type ToutiaoUploadPictureResponse = {
|
||||
url?: string;
|
||||
web_uri?: string;
|
||||
origin_web_uri?: string;
|
||||
rigin_web_uri?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
type ToutiaoSpiceImageResponse = {
|
||||
data?: {
|
||||
image_url?: string;
|
||||
image_uri?: string;
|
||||
image_width?: number;
|
||||
image_height?: number;
|
||||
};
|
||||
};
|
||||
|
||||
type ToutiaoPublishResponse = {
|
||||
code?: number;
|
||||
message?: string;
|
||||
data?: {
|
||||
pgc_id?: string | number;
|
||||
};
|
||||
};
|
||||
|
||||
async function fetchMediaInfo(context: Parameters<PublishAdapter["publish"]>[0]) {
|
||||
const response = await sessionFetchJson<ToutiaoMediaInfoResponse>(
|
||||
context.session,
|
||||
"https://mp.toutiao.com/mp/agw/media/get_media_info",
|
||||
{
|
||||
headers: {
|
||||
accept: "application/json, text/plain, */*",
|
||||
},
|
||||
},
|
||||
).catch(() => null);
|
||||
|
||||
return response?.data ?? null;
|
||||
}
|
||||
|
||||
async function uploadCover(
|
||||
context: Parameters<PublishAdapter["publish"]>[0],
|
||||
sourceUrl: string,
|
||||
) {
|
||||
const blob = await fetchImageBlob(sourceUrl);
|
||||
if (!blob) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (context.article.publish_type === "publish") {
|
||||
const form = new FormData();
|
||||
form.append("upfile", blob, "cover.png");
|
||||
const uploaded = await sessionFetchJson<ToutiaoUploadPictureResponse>(
|
||||
context.session,
|
||||
"https://mp.toutiao.com/mp/agw/article_material/photo/upload_picture",
|
||||
{
|
||||
method: "POST",
|
||||
body: form,
|
||||
},
|
||||
).catch(() => null);
|
||||
|
||||
if (!uploaded?.url || !uploaded.web_uri) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: 0,
|
||||
url: uploaded.url,
|
||||
uri: uploaded.web_uri,
|
||||
origin_uri: uploaded.origin_web_uri ?? uploaded.rigin_web_uri ?? "",
|
||||
thumb_width: uploaded.width ?? 0,
|
||||
thumb_height: uploaded.height ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append("image", blob, "cover.png");
|
||||
const first = await sessionFetchJson<ToutiaoSpiceImageResponse>(
|
||||
context.session,
|
||||
"https://mp.toutiao.com/spice/image?device_platform=web",
|
||||
{
|
||||
method: "POST",
|
||||
body: form,
|
||||
},
|
||||
).catch(() => null);
|
||||
|
||||
const imageUrl = first?.data?.image_url;
|
||||
if (!imageUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const second = new FormData();
|
||||
second.append("imageUrl", imageUrl);
|
||||
const final = await sessionFetchJson<ToutiaoSpiceImageResponse>(
|
||||
context.session,
|
||||
"https://mp.toutiao.com/spice/image?device_platform=web&need_cover_url=1",
|
||||
{
|
||||
method: "POST",
|
||||
body: second,
|
||||
},
|
||||
).catch(() => null);
|
||||
|
||||
if (!final?.data?.image_url || !first.data?.image_uri) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: "",
|
||||
url: final.data.image_url,
|
||||
uri: first.data.image_uri,
|
||||
ic_uri: "",
|
||||
thumb_width: first.data.image_width ?? 0,
|
||||
thumb_height: first.data.image_height ?? 0,
|
||||
extra: {
|
||||
from_content_uri: "",
|
||||
from_content: "0",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadContentImage(
|
||||
context: Parameters<PublishAdapter["publish"]>[0],
|
||||
sourceUrl: string,
|
||||
): Promise<string | null> {
|
||||
const blob = await fetchImageBlob(sourceUrl);
|
||||
if (!blob) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append("image", blob, "image.png");
|
||||
const uploaded = await sessionFetchJson<ToutiaoSpiceImageResponse>(
|
||||
context.session,
|
||||
"https://mp.toutiao.com/spice/image?device_platform=web",
|
||||
{
|
||||
method: "POST",
|
||||
body: form,
|
||||
},
|
||||
).catch(() => null);
|
||||
|
||||
return uploaded?.data?.image_url ?? null;
|
||||
}
|
||||
|
||||
function buildResultPayload(
|
||||
articleId: string,
|
||||
publishType: "publish" | "draft",
|
||||
mediaName: string,
|
||||
): Record<string, JsonValue> {
|
||||
const manageURL =
|
||||
publishType === "draft"
|
||||
? "https://mp.toutiao.com/profile_v4/graphic/publish"
|
||||
: "https://mp.toutiao.com/profile_v4/index";
|
||||
|
||||
return {
|
||||
platform: "toutiaohao",
|
||||
media_name: mediaName,
|
||||
external_article_id: articleId,
|
||||
external_manage_url: manageURL,
|
||||
external_article_url:
|
||||
publishType === "publish" ? `https://www.toutiao.com/article/${articleId}/` : null,
|
||||
publish_type: publishType,
|
||||
};
|
||||
}
|
||||
|
||||
export const toutiaoAdapter: PublishAdapter = {
|
||||
platform: "toutiaohao",
|
||||
async publish(context) {
|
||||
context.reportProgress("toutiao.media_info");
|
||||
const mediaInfo = await fetchMediaInfo(context);
|
||||
const user = mediaInfo?.user;
|
||||
if (!user?.id_str || !user.screen_name) {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: "头条号登录态失效,无法执行发布。",
|
||||
error: {
|
||||
code: "toutiaohao_not_logged_in",
|
||||
message: "未检测到头条号登录态",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
context.reportProgress("toutiao.normalize_html");
|
||||
const html = normalizeArticleHtml(context.article);
|
||||
if (!html) {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: "文章内容为空,无法推送到头条号。",
|
||||
error: {
|
||||
code: "article_content_empty",
|
||||
message: "html_content is empty",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
context.reportProgress("toutiao.upload_content_images");
|
||||
const processed = await uploadHtmlImages(html, async (sourceUrl) =>
|
||||
uploadContentImage(context, sourceUrl),
|
||||
);
|
||||
|
||||
context.reportProgress("toutiao.upload_cover");
|
||||
const cover = context.article.cover_asset_url?.trim()
|
||||
? await uploadCover(context, context.article.cover_asset_url.trim())
|
||||
: null;
|
||||
|
||||
const hasAdPermission = Boolean(
|
||||
mediaInfo?.media?.has_third_party_ad_permission || mediaInfo?.media?.has_toutiao_ad_permission,
|
||||
);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
content: processed.html,
|
||||
title: context.article.title,
|
||||
mp_editor_stat: JSON.stringify({ code_block: 1 }),
|
||||
is_refute_rumor: "0",
|
||||
save: context.article.publish_type === "publish" ? "1" : "0",
|
||||
timer_status: "0",
|
||||
draft_form_data: JSON.stringify({ coverType: 1 }),
|
||||
article_ad_type: hasAdPermission ? "3" : "2",
|
||||
pgc_feed_covers: JSON.stringify(cover ? [cover] : []),
|
||||
is_fans_article: "0",
|
||||
govern_forward: "0",
|
||||
praise: "0",
|
||||
disable_praise: "0",
|
||||
tree_plan_article: "0",
|
||||
activity_tag: "0",
|
||||
trends_writing_tag: "0",
|
||||
claim_exclusive: "0",
|
||||
info_source: JSON.stringify({
|
||||
source_type: 5,
|
||||
source_author_uid: "",
|
||||
time_format: "",
|
||||
position: {},
|
||||
}),
|
||||
});
|
||||
|
||||
context.reportProgress("toutiao.submit");
|
||||
const response = await sessionFetchJson<ToutiaoPublishResponse>(
|
||||
context.session,
|
||||
"https://mp.toutiao.com/mp/agw/article/publish?source=mp&type=article&aid=1231",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
referer: "https://mp.toutiao.com/profile_v4/graphic/publish",
|
||||
},
|
||||
body,
|
||||
},
|
||||
);
|
||||
|
||||
const articleId = response.data?.pgc_id != null ? String(response.data.pgc_id) : "";
|
||||
if (response.code !== 0 || !articleId) {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: response.message || "头条号发布失败。",
|
||||
error: {
|
||||
code: "toutiaohao_publish_failed",
|
||||
message: response.message || "toutiaohao_publish_failed",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const payload = buildResultPayload(articleId, context.article.publish_type, user.screen_name);
|
||||
const draft = context.article.publish_type === "draft";
|
||||
return {
|
||||
status: "succeeded",
|
||||
summary: draft ? "头条号草稿已创建,等待人工审核。" : "头条号发布成功。",
|
||||
payload,
|
||||
reviewUrl: typeof payload.external_manage_url === "string" ? payload.external_manage_url : null,
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user