333 lines
8.4 KiB
TypeScript
333 lines
8.4 KiB
TypeScript
import { normalizeFractionRateDisplay } from "../../shared/rate-normalizer";
|
|
import {
|
|
writeMarketListRequestSnapshot
|
|
} from "./market-list-request-snapshot";
|
|
import { parseMarketListResponse } from "./market-list-row";
|
|
|
|
const BRIDGE_MARKER = "__SCES_MARKET_PAGE_BRIDGE_INSTALLED__";
|
|
const MARKET_SEARCH_REQUEST_PATH = "/gw/api/gsearch/search_for_author_square";
|
|
const SERIALIZED_MARKET_ROWS_ATTRIBUTE = "data-sces-market-rows";
|
|
|
|
type MarketRow = {
|
|
attribute_datas?: Record<string, unknown>;
|
|
nick_name?: string;
|
|
star_id?: string;
|
|
};
|
|
|
|
declare global {
|
|
interface Window {
|
|
[BRIDGE_MARKER]?: boolean;
|
|
}
|
|
}
|
|
|
|
installMarketPageBridge();
|
|
|
|
function installMarketPageBridge() {
|
|
if (window[BRIDGE_MARKER]) {
|
|
syncSerializedMarketRows();
|
|
return;
|
|
}
|
|
|
|
window[BRIDGE_MARKER] = true;
|
|
installMarketRequestSnapshotBridge();
|
|
syncSerializedMarketRows();
|
|
|
|
const observer = new MutationObserver(() => {
|
|
syncSerializedMarketRows();
|
|
});
|
|
observer.observe(document.documentElement, {
|
|
childList: true,
|
|
subtree: true
|
|
});
|
|
|
|
window.setInterval(() => {
|
|
syncSerializedMarketRows();
|
|
}, 1000);
|
|
}
|
|
|
|
function installMarketRequestSnapshotBridge() {
|
|
installFetchSnapshotBridge();
|
|
installXmlHttpRequestSnapshotBridge();
|
|
}
|
|
|
|
function syncSerializedMarketRows() {
|
|
if (typeof document === "undefined") {
|
|
return;
|
|
}
|
|
|
|
const nextSerializedRows = JSON.stringify(readSerializedMarketRows());
|
|
if (
|
|
document.documentElement.getAttribute(SERIALIZED_MARKET_ROWS_ATTRIBUTE) !==
|
|
nextSerializedRows
|
|
) {
|
|
document.documentElement.setAttribute(
|
|
SERIALIZED_MARKET_ROWS_ATTRIBUTE,
|
|
nextSerializedRows
|
|
);
|
|
}
|
|
}
|
|
|
|
function installFetchSnapshotBridge() {
|
|
if (typeof window.fetch !== "function") {
|
|
return;
|
|
}
|
|
|
|
const originalFetch = window.fetch.bind(window);
|
|
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const requestSnapshot = readFetchSnapshot(input, init);
|
|
const response = await originalFetch(input, init);
|
|
if (requestSnapshot) {
|
|
const clonedResponse = response.clone();
|
|
void captureMarketSnapshotFromResponse(requestSnapshot, () =>
|
|
clonedResponse.json()
|
|
);
|
|
}
|
|
return response;
|
|
};
|
|
}
|
|
|
|
function installXmlHttpRequestSnapshotBridge() {
|
|
const OriginalXmlHttpRequest = window.XMLHttpRequest;
|
|
if (!OriginalXmlHttpRequest) {
|
|
return;
|
|
}
|
|
|
|
const originalOpen = OriginalXmlHttpRequest.prototype.open;
|
|
const originalSend = OriginalXmlHttpRequest.prototype.send;
|
|
const originalSetRequestHeader = OriginalXmlHttpRequest.prototype.setRequestHeader;
|
|
|
|
OriginalXmlHttpRequest.prototype.open = function (
|
|
method: string,
|
|
url: string | URL,
|
|
...rest: unknown[]
|
|
) {
|
|
(
|
|
this as XMLHttpRequest & {
|
|
__scesMarketSnapshot?: {
|
|
headers: Record<string, string>;
|
|
method: string;
|
|
url: string;
|
|
};
|
|
}
|
|
).__scesMarketSnapshot = {
|
|
headers: {},
|
|
method,
|
|
url: String(url)
|
|
};
|
|
return originalOpen.call(this, method, url, ...(rest as [boolean?, string?, string?]));
|
|
};
|
|
|
|
OriginalXmlHttpRequest.prototype.setRequestHeader = function (
|
|
name: string,
|
|
value: string
|
|
) {
|
|
(
|
|
this as XMLHttpRequest & {
|
|
__scesMarketSnapshot?: {
|
|
headers: Record<string, string>;
|
|
};
|
|
}
|
|
).__scesMarketSnapshot?.headers &&
|
|
((this as XMLHttpRequest & {
|
|
__scesMarketSnapshot?: {
|
|
headers: Record<string, string>;
|
|
};
|
|
}).__scesMarketSnapshot!.headers[name] = value);
|
|
return originalSetRequestHeader.call(this, name, value);
|
|
};
|
|
|
|
OriginalXmlHttpRequest.prototype.send = function (body?: Document | XMLHttpRequestBodyInit | null) {
|
|
const snapshotState = (
|
|
this as XMLHttpRequest & {
|
|
__scesMarketSnapshot?: {
|
|
body?: string;
|
|
headers: Record<string, string>;
|
|
method: string;
|
|
url: string;
|
|
};
|
|
}
|
|
).__scesMarketSnapshot;
|
|
if (snapshotState) {
|
|
snapshotState.body = typeof body === "string" ? body : undefined;
|
|
this.addEventListener("load", () => {
|
|
if (this.status < 200 || this.status >= 300 || typeof this.responseText !== "string") {
|
|
return;
|
|
}
|
|
|
|
void captureMarketSnapshotFromResponse(snapshotState, async () =>
|
|
JSON.parse(this.responseText)
|
|
);
|
|
});
|
|
}
|
|
|
|
return originalSend.call(this, body);
|
|
};
|
|
}
|
|
|
|
async function captureMarketSnapshotFromResponse(
|
|
snapshot: {
|
|
body?: string;
|
|
headers?: Record<string, string>;
|
|
method: string;
|
|
url: string;
|
|
},
|
|
readPayload: () => Promise<unknown>
|
|
) {
|
|
if (!isMarketSearchRequest(snapshot.url)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const payload = await readPayload();
|
|
if (!parseMarketListResponse(payload)) {
|
|
return;
|
|
}
|
|
|
|
writeMarketListRequestSnapshot(document, {
|
|
body: snapshot.body,
|
|
headers: snapshot.headers,
|
|
method: snapshot.method,
|
|
url: snapshot.url
|
|
});
|
|
} catch {}
|
|
}
|
|
|
|
function readSerializedMarketRows() {
|
|
const marketList = readMarketList();
|
|
return marketList
|
|
.map((row) => {
|
|
const attributeDatas = isRecord(row.attribute_datas) ? row.attribute_datas : {};
|
|
const singleVideoAfterSearchRate = readNormalizedFractionRate(
|
|
attributeDatas.avg_search_after_view_rate_30d
|
|
);
|
|
return {
|
|
authorId:
|
|
readString(row.star_id) ?? readString(attributeDatas.id) ?? "",
|
|
authorName:
|
|
readString(attributeDatas.nickname) ?? readString(row.nick_name) ?? "",
|
|
coreUserId: readString(attributeDatas.core_user_id) ?? undefined,
|
|
singleVideoAfterSearchRate
|
|
};
|
|
})
|
|
.filter((row) => Boolean(row.authorId || row.authorName));
|
|
}
|
|
|
|
function readFetchSnapshot(
|
|
input: RequestInfo | URL,
|
|
init?: RequestInit
|
|
): {
|
|
body?: string;
|
|
headers?: Record<string, string>;
|
|
method: string;
|
|
url: string;
|
|
} | null {
|
|
const request = input instanceof Request ? input : null;
|
|
const method = init?.method ?? request?.method ?? "GET";
|
|
const url = request?.url ?? String(input);
|
|
const body =
|
|
typeof init?.body === "string"
|
|
? init.body
|
|
: typeof request?.bodyUsed === "boolean" && request.bodyUsed
|
|
? undefined
|
|
: undefined;
|
|
const headers = serializeHeaders(init?.headers ?? request?.headers);
|
|
|
|
return {
|
|
body,
|
|
headers,
|
|
method,
|
|
url
|
|
};
|
|
}
|
|
|
|
function serializeHeaders(
|
|
headers: HeadersInit | undefined
|
|
): Record<string, string> | undefined {
|
|
if (!headers) {
|
|
return undefined;
|
|
}
|
|
|
|
if (headers instanceof Headers) {
|
|
return Object.fromEntries(headers.entries());
|
|
}
|
|
|
|
if (Array.isArray(headers)) {
|
|
return Object.fromEntries(headers);
|
|
}
|
|
|
|
return Object.fromEntries(
|
|
Object.entries(headers).map(([key, value]) => [key, String(value)])
|
|
);
|
|
}
|
|
|
|
function readMarketList(): MarketRow[] {
|
|
if (typeof document === "undefined") {
|
|
return [];
|
|
}
|
|
|
|
const marketRoot = document.querySelector(".base-author-list") as
|
|
| (HTMLElement & {
|
|
__vue__?: {
|
|
_setupState?: Record<string, unknown>;
|
|
};
|
|
})
|
|
| null;
|
|
const setupState = marketRoot?.__vue__?._setupState;
|
|
if (!setupState) {
|
|
return [];
|
|
}
|
|
|
|
for (const value of Object.values(setupState)) {
|
|
const candidate = unwrapVueRef(value);
|
|
if (Array.isArray(candidate) && looksLikeMarketList(candidate)) {
|
|
return candidate as MarketRow[];
|
|
}
|
|
|
|
if (!isRecord(candidate) || !Array.isArray(candidate.marketList)) {
|
|
continue;
|
|
}
|
|
|
|
if (looksLikeMarketList(candidate.marketList)) {
|
|
return candidate.marketList as MarketRow[];
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
function isMarketSearchRequest(url: string): boolean {
|
|
return (
|
|
url === MARKET_SEARCH_REQUEST_PATH ||
|
|
url.startsWith(`${MARKET_SEARCH_REQUEST_PATH}?`) ||
|
|
url.includes(`${MARKET_SEARCH_REQUEST_PATH}?`) ||
|
|
url.endsWith(MARKET_SEARCH_REQUEST_PATH)
|
|
);
|
|
}
|
|
|
|
function looksLikeMarketList(value: unknown[]): boolean {
|
|
const firstRow = value[0];
|
|
return isRecord(firstRow) && ("star_id" in firstRow || "attribute_datas" in firstRow);
|
|
}
|
|
|
|
function unwrapVueRef(value: unknown): unknown {
|
|
if (isRecord(value) && "value" in value) {
|
|
return value.value;
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
function readString(value: unknown): string | null {
|
|
return typeof value === "string" ? value : null;
|
|
}
|
|
|
|
function readNormalizedFractionRate(value: unknown): string | undefined {
|
|
return typeof value === "string"
|
|
? normalizeFractionRateDisplay(value) ?? undefined
|
|
: undefined;
|
|
}
|