diff --git a/README.md b/README.md index 3661990..8049886 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Currently, it supports the following platforms: * [Truth Social](https://truth.social) * [Pinterest](https://pinterest.com) * [RedNote/Xiaohongshu](https://xiaohongshu.com) (posts and comments) +* [Weibo](https://weibo.com) (posts and comments) Platform support requires regular maintenance to keep up with changes to the platforms. If something does not work, we welcome issues and pull requests. See 'Limitations' below for some known limitations to data capture. @@ -90,6 +91,13 @@ platform. The following limitations are known: * Live streams * *Douyin* * douyin.com/search/ page is untested (requires login) +* *Weibo* is only supported on the desktop site, weibo.com, and is untested while logged in. Logged out, Weibo itself + does not show: + * Search results + * A profile's timeline (its video tab does work) + * Reposts of a post +* *Weibo* items that cannot be captured: + * Posts on video pages (weibo.com/tv); their comments are captured For some platforms, the level of detail of the data that can be collected depends on the page it is captured from: @@ -97,6 +105,9 @@ For some platforms, the level of detail of the data that can be collected depend of the post. * *RedNote/Xiaohongshu* items will often lack the item's post description, timestamp, and video URL, unless captured by opening the post's own page/clicking it in an overview. +* *Weibo* shortens long posts in feeds and lists. Opening the post's own page captures its full text; with 'Skip + duplicates' or 'Update existing' selected, the full post replaces the shortened one. Mapped items say whether the + text was shortened. Note that these are *known* limitations; data capture may break or change based on platform changes. Always cross-reference captured data with what you are seeing in your browser. diff --git a/images/platform-icons/weibo.png b/images/platform-icons/weibo.png new file mode 100644 index 0000000..37a7da5 Binary files /dev/null and b/images/platform-icons/weibo.png differ diff --git a/modules/_loader.js b/modules/_loader.js index eed36b3..9c8c765 100644 --- a/modules/_loader.js +++ b/modules/_loader.js @@ -17,7 +17,9 @@ async function load() { await import("./rednote.js"), await import("./rednote-comments.js"), await import("./twitter.js"), - await import("./gab.js") + await import("./gab.js"), + await import("./weibo.js"), + await import("./weibo-comments.js") ]; for(const module of imported_modules) { diff --git a/modules/weibo-comments.js b/modules/weibo-comments.js new file mode 100644 index 0000000..ccd0aee --- /dev/null +++ b/modules/weibo-comments.js @@ -0,0 +1,162 @@ +export const MODULE_NAME = 'Weibo (comments)'; +export const DOMAIN = 'weibo.com'; +export const MODULE_ID = 'weibo-comments'; + +/** + * Collect comments on Weibo posts from weibo.com + * + * Comments load from /ajax/statuses/buildComments, on a post's own page and + * on video pages. The first requests for a post list its top-level comments, + * each with up to two of its replies in `comments`; opening a comment's + * replies lists all of them. Replies are collected as items of their own. + * + * @param {string} response Content of the captured response + * @param {string} source_platform_url URL of the page the user is on + * @param {string} source_url URL of the captured response + * @returns {Array} Weibo comments + */ +export function capture(response, source_platform_url, source_url) { + const domain = source_platform_url.split("/")[2].toLowerCase().replace(/^www\./, ''); + if (!domain.endsWith('weibo.com')) { + return []; + } + + if (!response) { + return []; + } + + let data; + try { + data = JSON.parse(response); + } catch (e) { + return []; + } + + if (!data || !Array.isArray(data.data)) { + return []; + } + + const comments = []; + for (const comment of data.data) { + if (!is_comment(comment)) { + continue; + } + + comments.push(comment); + for (const reply of Array.isArray(comment.comments) ? comment.comments : []) { + if (is_comment(reply)) { + comments.push(reply); + } + } + } + + return comments; +} + +// capture() uses this helper, and map_item() uses it too. It stays outside +// the block 4CAT's map_item sync replaces, so that a sync cannot remove it. + +/** + * Whether an object is a Weibo comment + * + * Comments have their own ID, the ID of the comment their thread starts with + * (their own, for a top-level comment), an author and a creation date. + * + * @param {*} item + * @returns {boolean} + */ +function is_comment(item) { + return typeof item === 'object' && item !== null + && typeof item.idstr === 'string' + && typeof item.rootidstr === 'string' + && typeof item.user === 'object' && item.user !== null + && typeof item.created_at === 'string'; +} + +// === auto-generated by 4cat map_item sync — BLOCK REPLACED AUTOMATICALLY === +// (written by hand: 4CAT has no Weibo data source yet. The first sync from +// datasources/weibo_comments/search_weibo_comments.py replaces this block.) +/** + * Map a Weibo comment to a flat item + * + * @param {Object} comment Comment as captured, with `__import_meta` + * @returns {MappedItem} + */ +export function map_item(comment) { + if (!is_comment(comment)) { + const source = comment.__import_meta?.source_url ?? ""; + throw new MapItemException(`Not a Weibo comment (source: ${source || 'unknown'})`); + } + + const user = comment.user; + const post = commented_post(comment); + const is_reply = comment.rootidstr !== comment.idstr; + const unix_timestamp = weibo_unix_timestamp(comment.created_at); + + return new MappedItem({ + collected_from_url: normalize_url_encoding(comment.__import_meta?.source_platform_url ?? ""), + id: comment.idstr, + thread_id: post.id ?? new MissingMappedField(""), + post_id: post.id ?? new MissingMappedField(""), + post_url: post.id && post.author_id + ? `https://weibo.com/${post.author_id}/${post.id}` : new MissingMappedField(""), + author: user.screen_name, + author_id: user.idstr, + author_avatar_url: user.avatar_hd ?? user.profile_image_url ?? "", + body: typeof comment.text_raw === 'string' ? comment.text_raw : strip_tags(comment.text ?? ""), + timestamp: unix_timestamp !== null ? formatUtcTimestamp(unix_timestamp) : new MissingMappedField(""), + unix_timestamp: unix_timestamp ?? new MissingMappedField(""), + likes: comment.like_counts ?? comment.like_count ?? new MissingMappedField(""), + replies: is_reply ? "" : value_or_missing(comment, 'total_number', ""), + is_reply: is_reply ? "yes" : "no", + root_comment_id: comment.rootidstr, + reply_to_comment_id: comment.reply_comment?.idstr ?? "", + reply_to_author: comment.reply_comment?.user?.screen_name ?? "", + location: comment.source ? comment.source.replace(/^来自\s*/, '') : new MissingMappedField(""), + is_liked_by_post_author: comment.isLikedByMblogAuthor ? "yes" : "no", + }); +} + +/** + * The post a comment belongs to + * + * Comments do not name their post in a field of its own. Its ID and its + * author's ID are in `analysis_extra`, which reads like + * "author_uid:5781292544|mid:5353703604491306|rid:5246087537427886552". + * + * @param {Object} comment + * @returns {{id: string|undefined, author_id: string|undefined}} + */ +function commented_post(comment) { + const fields = Object.fromEntries(String(comment.analysis_extra ?? "") + .split("|") + .map(field => field.split(":")) + .filter(field => field.length === 2)); + return {id: fields.mid, author_id: fields.author_uid}; +} + +/** + * Convert a Weibo date to a UNIX timestamp + * + * Weibo dates look like "Thu Oct 15 10:22:13 +0800 2026". + * + * @param {string} created_at + * @returns {number|null} Seconds since the epoch, or null if not a Weibo date + */ +function weibo_unix_timestamp(created_at) { + const parts = /^\w{3} (\w{3}) (\d{1,2}) (\d{2}):(\d{2}):(\d{2}) ([+-])(\d{2})(\d{2}) (\d{4})$/.exec(created_at ?? ""); + if (!parts) { + return null; + } + + const [, month, day, hours, minutes, seconds, sign, offset_hours, offset_minutes, year] = parts; + const month_index = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'].indexOf(month); + if (month_index < 0) { + return null; + } + + const as_if_utc = Date.UTC(+year, month_index, +day, +hours, +minutes, +seconds) / 1000; + const offset = (sign === '-' ? -1 : 1) * (+offset_hours * 3600 + +offset_minutes * 60); + return as_if_utc - offset; +} +// === end auto-generated === diff --git a/modules/weibo.js b/modules/weibo.js new file mode 100644 index 0000000..127a621 --- /dev/null +++ b/modules/weibo.js @@ -0,0 +1,288 @@ +export const MODULE_NAME = 'Weibo'; +export const DOMAIN = 'weibo.com'; +export const TOOLTIP = 'Weibo shortens long posts in feeds and lists. Open a post\'s own page to collect its full ' + + 'text. With "Skip duplicates" or "Update existing" selected, the full post then replaces the shortened one; with ' + + '"Keep duplicates", both are kept.'; + +/** + * Collect Weibo posts from weibo.com + * + * The desktop site loads everything as JSON from its own API; the page HTML + * is an empty shell. Posts have the same format wherever they appear: the hot + * feed and hot lists (/ajax/feed/hottimeline), a post's own page + * (/ajax/statuses/show), the video tab of a profile + * (/ajax/profile/getWaterFallContent) and super-topic pages + * (/ajax_proxy/chaohua/page). A repost carries the post it reposts in + * `retweeted_status`, which stays part of the repost. + * + * @param {string} response Content of the captured response + * @param {string} source_platform_url URL of the page the user is on + * @param {string} source_url URL of the captured response + * @returns {Array} Weibo posts + */ +export function capture(response, source_platform_url, source_url) { + const domain = source_platform_url.split("/")[2].toLowerCase().replace(/^www\./, ''); + if (!domain.endsWith('weibo.com')) { + return []; + } + + if (!response) { + return []; + } + + let data; + try { + data = JSON.parse(response); + } catch (e) { + return []; + } + + // traverse_data skips the object it is given; on a post's own page, that + // object is the post + return traverse_data([data], function (item) { + if (is_post(item)) { + return item; + } + }); +} + +/** + * Decide whether a newly captured copy of a post replaces the stored one + * + * A complete copy replaces a shortened one, and a shortened copy never + * replaces a complete one. For any other pair this returns null, which + * Zeeschuimer handles as it would for a module without this function; false + * would also stop "Update existing" from refreshing a stored post's counts. + * + * @param {Object} incoming_item Post as just captured + * @param {Object} existing_item Stored record, with the post in `data` + * @returns {boolean|null} + */ +export function overwrite_partial(incoming_item, existing_item) { + if (!existing_item || !existing_item.data) { + return null; + } + + const existing_is_short = has_shortened_text(existing_item.data); + const incoming_is_short = has_shortened_text(incoming_item); + + if (existing_is_short && !incoming_is_short) { + return true; + } + + if (!existing_is_short && incoming_is_short) { + return false; + } + + return null; +} + +// capture() and overwrite_partial() use the helpers below, and map_item() +// uses them too. They stay outside the block 4CAT's map_item sync replaces, so +// that a sync cannot remove them. + +/** + * Whether an object is a Weibo post + * + * Posts have a short ID used in their URL (`mblogid`), an author and a + * creation date. Comments, users and trending search terms do not have all + * three. + * + * @param {*} item + * @returns {boolean} + */ +function is_post(item) { + return typeof item === 'object' + && typeof item.mblogid === 'string' + && typeof item.user === 'object' && item.user !== null + && typeof item.created_at === 'string'; +} + +/** + * Whether a post's text, or that of the post it reposts, is cut short + * + * Feeds and lists send only the start of a long post and set `isLongText`. + * A post's own page sends the full text as well, in `longText`. + * + * @param {Object} post + * @returns {boolean} + */ +function has_shortened_text(post) { + return is_shortened(post) || (!!post.retweeted_status && is_shortened(post.retweeted_status)); +} + +function is_shortened(post) { + return post.isLongText === true && !post.longText; +} + +// === auto-generated by 4cat map_item sync — BLOCK REPLACED AUTOMATICALLY === +// (written by hand: 4CAT has no Weibo data source yet. The first sync from +// datasources/weibo/search_weibo.py replaces this block.) +/** + * Map a Weibo post to a flat item + * + * @param {Object} post Post as captured, with `__import_meta` + * @returns {MappedItem} + */ +export function map_item(post) { + if (!is_post(post)) { + const source = post.__import_meta?.source_url ?? ""; + throw new MapItemException(`Not a Weibo post (source: ${source || 'unknown'})`); + } + + const user = post.user; + const reposted = post.retweeted_status ?? null; + const unix_timestamp = weibo_unix_timestamp(post.created_at); + + return new MappedItem({ + collected_from_url: normalize_url_encoding(post.__import_meta?.source_platform_url ?? ""), + id: post.idstr, + thread_id: post.idstr, + url: `https://weibo.com/${user.idstr}/${post.mblogid}`, + body: post_text(post), + body_is_shortened: is_shortened(post) ? "yes" : "no", + timestamp: unix_timestamp !== null ? formatUtcTimestamp(unix_timestamp) : new MissingMappedField(""), + unix_timestamp: unix_timestamp ?? new MissingMappedField(""), + author: user.screen_name, + author_id: user.idstr, + author_avatar_url: user.avatar_hd ?? user.profile_image_url ?? "", + author_verified: user.verified ? "yes" : "no", + author_followers: value_or_missing(user, 'followers_count', ""), + likes: post.attitudes_count, + comments: post.comments_count, + reposts: post.reposts_count, + hashtags: hashtags(post).join(","), + image_urls: image_urls(post).join(","), + video_url: video_url(post), + location: post.region_name ? post.region_name.replace(/^发布于\s*/, '') : new MissingMappedField(""), + posted_with: post.source ? strip_tags(post.source) : "", + is_promoted: post.isAd ? "yes" : "no", + is_repost: reposted ? "yes" : "no", + reposted_id: reposted ? reposted.idstr : "", + reposted_url: reposted && reposted.user ? `https://weibo.com/${reposted.user.idstr}/${reposted.mblogid}` : "", + reposted_author: reposted && reposted.user ? reposted.user.screen_name : "", + reposted_body: reposted ? post_text(reposted) : "", + reposted_body_is_shortened: reposted && is_shortened(reposted) ? "yes" : "no", + }); +} + +/** + * A post's text, in full when it was captured in full + * + * `text_raw` is plain text, `text` the same with HTML links. Super-topic + * pages send only `text`. + * + * @param {Object} post + * @returns {string} + */ +function post_text(post) { + if (post.longText && post.longText.content) { + return post.longText.content; + } + + if (typeof post.text_raw === 'string') { + return post.text_raw; + } + + return strip_tags(post.text ?? ""); +} + +/** + * Hashtags in a post, without the # on either side + * + * Weibo hashtags are enclosed in #, as in #广交会#. `topic_struct` lists + * them when present. + * + * @param {Object} post + * @returns {string[]} + */ +function hashtags(post) { + if (Array.isArray(post.topic_struct) && post.topic_struct.length > 0) { + return post.topic_struct.map(topic => topic.topic_title).filter(Boolean); + } + + return [...post_text(post).matchAll(/#([^#\n]+)#/g)].map(match => match[1]); +} + +/** + * Full-size image URLs, in the order the post shows them + * + * Images are in `pic_infos`, keyed by the IDs in `pic_ids`; posts that mix + * images and videos have them in `mix_media_info` instead. + * + * @param {Object} post + * @returns {string[]} + */ +function image_urls(post) { + const pictures = []; + for (const pic_id of post.pic_ids ?? []) { + if (post.pic_infos && post.pic_infos[pic_id]) { + pictures.push(post.pic_infos[pic_id]); + } + } + + for (const media of post.mix_media_info?.items ?? []) { + if (media.type === 'pic' && media.data) { + pictures.push(media.data); + } + } + + const urls = pictures + .map(picture => picture.largest?.url ?? picture.original?.url ?? picture.large?.url) + .filter(Boolean); + return [...new Set(urls)]; +} + +/** + * URL of a post's video, if it has one + * + * @param {Object} post + * @returns {string} + */ +function video_url(post) { + const pages = [post.page_info]; + for (const media of post.mix_media_info?.items ?? []) { + if (media.type === 'video') { + pages.push(media.data); + } + } + + for (const page of pages) { + const media_info = page?.object_type === 'video' ? page.media_info : null; + if (media_info) { + const url = media_info.mp4_720p_mp4 || media_info.mp4_hd_url || media_info.stream_url_hd + || media_info.stream_url; + if (url) { + return url; + } + } + } + + return ""; +} + +/** + * Convert a Weibo date to a UNIX timestamp + * + * Weibo dates look like "Thu Oct 15 10:22:13 +0800 2026". + * + * @param {string} created_at + * @returns {number|null} Seconds since the epoch, or null if not a Weibo date + */ +function weibo_unix_timestamp(created_at) { + const parts = /^\w{3} (\w{3}) (\d{1,2}) (\d{2}):(\d{2}):(\d{2}) ([+-])(\d{2})(\d{2}) (\d{4})$/.exec(created_at ?? ""); + if (!parts) { + return null; + } + + const [, month, day, hours, minutes, seconds, sign, offset_hours, offset_minutes, year] = parts; + const month_index = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'].indexOf(month); + if (month_index < 0) { + return null; + } + + const as_if_utc = Date.UTC(+year, month_index, +day, +hours, +minutes, +seconds) / 1000; + const offset = (sign === '-' ? -1 : 1) * (+offset_hours * 3600 + +offset_minutes * 60); + return as_if_utc - offset; +} +// === end auto-generated === diff --git a/tests/README.md b/tests/README.md index da60015..e5c39ce 100644 --- a/tests/README.md +++ b/tests/README.md @@ -7,6 +7,7 @@ different purpose and a different runtime environment: |----------------------------------|-----------------------------------------------------------|--------------------|---------------------------------|----------------------------------------| | Selenium integration | Page captures real items from each supported platform | Real Firefox | Reviewer-supervised, manual | Firefox profile, sometimes a human | | Duplicate-behavior unit (Jest) | DB merge / keep / update semantics in isolation | jsdom + fake-IDB | `npm test` (every push) | None | +| Navigation index (Jest) | The real background script counts navigations and dedupes across them | jsdom + fake-IDB | `npm test` (every push) | None | | Module load smoke (Jest, Tier 1) | Each `modules/*.js` parses and imports cleanly | jsdom | `npm test` (every push) | None | | Navigation index (Jest) | Tab and navigation bookkeeping in `js/zs-background.js` | jsdom + fake-IDB | `npm test` (every push) | None | | ESLint | Every name `js/` and `modules/` use is defined somewhere | Node | `npm run lint` (every push) | None | @@ -47,6 +48,10 @@ against items researchers have already collected and uploaded to 4CAT. RedNote also limits logged-out browsing: after enough page views, every page redirects to the login page, even after clearing cookies. +Weibo is tested logged out as well, since no current developer has an account. +Logged out, Weibo shows no search results, profile timelines or reposts, so +the Weibo tests do not cover them. + Run `test.py` to run tests. Required non-standard libraries are in `requirements.txt`. @@ -59,13 +64,65 @@ Tests are defined in `tests.json` with the following structure: "url": { "expected": 0, "more-after-scroll": false, - "wait": 10 + "wait": 10, + "comment": "free-form note, ignored by the runner", + "captcha-selector": "div#captcha_container" } } } } ``` +`expected` may be a single number or a `[low, high]` range, inclusive. A range +is usually the honest answer: platforms load in batches and a slow page lands +on a lower count without anything being wrong. `captcha-selector` names an +element whose appearance pauses the run so a human can solve the captcha. + +Douyin is the exception to the borrowed-login note above: no maintainer +currently has an account, so its expectations record what a **logged-out** +visitor sees. Search is not covered at all, because Douyin will not show +results without a login. + +#### Sending a run to 4CAT + +A capture is only useful if 4CAT can read it, and a module that starts +returning a new shape breaks that quietly. `--fourcat` uploads what each +platform captured, as one dataset per platform, once that platform's tests +finish: + +```bash +python3 test.py --fourcat http://localhost +``` + +Uploading is deliberate about a few things: + +- **One dataset per platform, not per test case.** Which case an item came from + is recoverable afterwards from `source_platform_url` in the NDJSON. +- **A bad run is uploaded too.** A capture 4CAT cannot read is the finding, not + a reason to withhold it. +- **You must be logged in to that 4CAT in the test browser.** Uploads + authenticate with the browser session, not an API key, so the script checks at + startup and waits rather than discovering it after the first platform. Note + that a session cookie is not written to disk, so a profile that was logged in + when it was copied usually is not logged in when the tests run - expect to log + in at the prompt. +- **Failures ask rather than abort.** Not logged in, rate limited, or refused: + the run pauses with `[Enter] retry`, `[s]` skip this platform, `[n]` stop + uploading. With no terminal attached it takes the last of those and carries + on. + +The run ends with the dataset URLs and a line to paste into `tests/.env`, so the +`map_item` comparator can be pointed straight at what was just captured: + +``` +FOURCAT_DATASETS=a1b2c3,d4e5f6 +``` + +`--dump-dir ` writes the same NDJSON to disk instead of, or as well as, +uploading. It is byte for byte what an upload sends, so a dump can be uploaded +by hand later. `--keep-open` leaves the browser running at the end so the +captured items can be looked at in the interface. + ### Jest suites **Prerequisites** @@ -99,6 +156,32 @@ Coverage: - Platform isolation: same `item_id` on different platforms are independent - Tie-breaker: when `last_updated` is equal, prefer higher `id` +#### Navigation index + +`tests/nav-index.test.js` evaluates `js/zs-background.js` the way the browser +does - as a classic script against `Dexie` and `browser` globals - and calls +the functions it defines. + +This is deliberately not a re-implementation. The duplicate-behavior suite +above supplies `nav_index` values as fixtures (`'1:1:0'`, `'1:1:1'`), which +assumes navigations are counted correctly without ever running the code that +counts them; a `nav_handler` that stored `NaN` on every navigation therefore +went unnoticed while every test passed. + +Coverage: +- `nav_handler` assigns a first index and increments it on each navigation +- the stored index stays a usable number +- consecutive navigations produce distinct indices +- the object form `webNavigation.onCommitted` passes is unwrapped +- tabs are counted independently +- an item seen twice on one page is stored once +- "keep duplicates" records an item once per page it appears on +- "skip duplicates" keeps only the first sighting + +The duplicate-handling cases browse three pages rather than two: an index that +is merely wrong still differs from the initial one, so a single navigation +cannot tell a working counter from a stuck one. + #### Module load smoke (Tier 1) For every file under `modules/*.js`, `tests/map_item.test.js` asserts the @@ -156,7 +239,7 @@ A CLI flag crosses every shell. ### Running ```bash -# everything that's hermetic — duplicate-behavior unit + module load smoke +# everything that's hermetic — duplicate-behavior, nav index, module smoke npm test # watch mode for the same @@ -181,6 +264,9 @@ npm run test:compare -- --all - **Pure data transformation, no live external state, runs anywhere.** Duplicate-behavior unit suite (DB logic) or the Tier 1 smoke (`map_item` static checks). +- **Behaviour of the background script itself.** Navigation-index suite. Call + the real functions from `js/zs-background.js` rather than restating what they + are supposed to do. - **Field-by-field correctness against 4CAT's Python `map_item`.** Tier 2 comparator. Add a dataset to `FOURCAT_DATASETS` that covers the case; the comparator will pick it up. diff --git a/tests/tests.json b/tests/tests.json index de5cebc..5ec5f34 100644 --- a/tests/tests.json +++ b/tests/tests.json @@ -464,5 +464,54 @@ "more-after-scroll": true } } + }, + "weibo.com": { + "Hot feed": { + "https://weibo.com/": { + "comment": "Logged out, weibo.com redirects to /newlogin, which shows the hot feed", + "expected": 10, + "more-after-scroll": true, + "wait": 8 + } + }, + "Hot list": { + "https://weibo.com/newlogin?tabtype=list&gid=1028039999&openLoginLayer=0&url=https://weibo.com/": { + "expected": [10, 20], + "more-after-scroll": true, + "wait": 8 + } + }, + "Post page": { + "https://weibo.com/1644114654/RmKa9CzPq": { + "expected": 1, + "more-after-scroll": false + } + }, + "Profile, video tab": { + "https://weibo.com/u/1644114654?tabtype=newVideo": { + "comment": "The profile's main timeline needs a login; the video tab does not", + "expected": 20, + "more-after-scroll": true, + "wait": 8 + } + }, + "Super-topic": { + "https://weibo.com/p/10080892b1f367dec8ee89a39109d0b5fe9c0d/super_index": { + "comment": "About 8 posts per page; the first page loads with fewer shown than captured", + "expected": [1, 10], + "more-after-scroll": true, + "wait": 8 + } + } + }, + "weibo-comments": { + "Post page": { + "https://weibo.com/5908064369/RlZSU3Pg2": { + "comment": "Top-level comments plus up to two replies to each", + "expected": [20, 60], + "more-after-scroll": true, + "wait": 8 + } + } } } \ No newline at end of file