Files
2026-07-13 12:29:17 +08:00

141 lines
5.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""LinkedIn 单帖分析抓取。
登录一次后,Cookie(含 `li_at`)持久化在 .auth-linkedin/,之后直接复用。
LinkedIn 把单帖分析(/analytics/post-summary/SSR/inline 进页面,没有稳定可拦的
voyager XHR,所以读渲染后 DOM 文本、按已知标签锚点解析(见 extract.py)。
和 douyin-session 一样:一次抓取共享一个持久化 Chromium 会话。
"""
from __future__ import annotations
import asyncio
import re
from typing import Any
from playwright.async_api import BrowserContext, Page, async_playwright
from paths import auth_dir, debug_dir
from extract import parse_post_meta, parse_post_summary
FEED = "https://www.linkedin.com/feed/"
POST_SUMMARY = "https://www.linkedin.com/analytics/post-summary/urn:li:activity:{activity_id}/"
_ACTIVITY_RE = re.compile(r"urn:li:activity:(\d+)|activity[:-](\d+)|/(\d{15,25})\b")
def extract_activity_id(raw: str) -> str:
"""从帖子 URL 或裸 id 提取 activity_id。
支持:
- 裸 id`7470493738918920193`
- 帖子链接:`https://www.linkedin.com/feed/update/urn:li:activity:7470493738918920193/`
- 分析链接:`https://www.linkedin.com/analytics/post-summary/urn:li:activity:7470493738918920193/`
抽不出时原样返回(交给上层报错)。
"""
raw = raw.strip()
if raw.isdigit():
return raw
m = _ACTIVITY_RE.search(raw)
if m:
return next(g for g in m.groups() if g)
return raw
class Session:
"""单浏览器会话,持久化登录态。"""
def __init__(self, ctx: BrowserContext, pw: Any) -> None:
self.ctx = ctx
self.pw = pw
@classmethod
async def open(cls, headless: bool = False) -> "Session":
pw = await async_playwright().start()
auth_path = auth_dir()
auth_path.mkdir(parents=True, exist_ok=True)
ctx = await pw.chromium.launch_persistent_context(
user_data_dir=str(auth_path),
headless=headless,
viewport={"width": 1440, "height": 900},
args=["--disable-blink-features=AutomationControlled"],
)
return cls(ctx, pw)
async def close(self) -> None:
try:
await self.ctx.close()
finally:
await self.pw.stop()
async def _logged_in(ctx: BrowserContext) -> bool:
cookies = await ctx.cookies("https://www.linkedin.com")
return any(c["name"] == "li_at" for c in cookies)
async def ensure_login(timeout_s: int = 300) -> bool:
"""打开 LinkedIn,等用户登录;检测到 li_at cookie 后返回。"""
sess = await Session.open()
try:
page = await sess.ctx.new_page()
await page.goto(FEED)
print(f"[登录] 在弹出的 Chromium 里登录 LinkedIn。最多等 {timeout_s} 秒……")
for i in range(timeout_s):
if await _logged_in(sess.ctx):
print(f"[登录] ✓ 检测到 li_at(用时 {i}s)。Cookie 已存到 .auth-linkedin/")
await asyncio.sleep(1)
return True
await asyncio.sleep(1)
print("[登录] 超时未检测到登录态。")
return False
finally:
await sess.close()
async def _scrape_post(page: Page, activity_id: str) -> dict:
await page.goto(
POST_SUMMARY.format(activity_id=activity_id),
wait_until="domcontentloaded",
timeout=60000,
)
await asyncio.sleep(7)
if "post-summary" not in page.url:
print(f"[post] ⚠ 可能被登出 / 重定向:{page.url}")
txt = await page.inner_text("body")
dbg = debug_dir()
dbg.mkdir(parents=True, exist_ok=True)
(dbg / f"post_{activity_id}.txt").write_text(txt, encoding="utf-8")
result = parse_post_summary(txt)
result["meta"] = parse_post_meta(txt)
result["activity_id"] = activity_id
if result["metrics"].get("impressions") is None:
print(f"[post] ⚠ {activity_id} 没抽到 impressions(结构可能变了,看 {dbg}/post_{activity_id}.txt")
return result
async def fetch_post_summary(activity_id: str, headless: bool = True) -> dict:
"""DOM 抽取单帖分析(/analytics/post-summary/)。只能看**你自己**的帖子。"""
sess = await Session.open(headless=headless)
try:
if not await _logged_in(sess.ctx):
print("[post] 未登录。先跑:python review.py login")
return {}
page = await sess.ctx.new_page()
return await _scrape_post(page, activity_id)
finally:
await sess.close()
async def fetch_all(activity_id: str, headless: bool = True) -> dict:
"""抓单帖分析,返回 {'post': {...}}(与 review.py 的渲染入口对齐)。"""
print(f" → 打开单帖分析页 activity:{activity_id}")
post = await fetch_post_summary(activity_id, headless=headless)
if post:
m = post.get("metrics", {})
print(f" impressions={m.get('impressions')} reactions={m.get('reactions')} comments={m.get('comments')}")
return {"post": post}
if __name__ == "__main__":
asyncio.run(ensure_login())