Skip to content

Commit 94bfd24

Browse files
chengtan9907claude
andcommitted
Fetch paper sources concurrently instead of one at a time
The daily pull was almost entirely blocked on network waits taken in series. Measured on the same query (4 arXiv categories + 12 journals): 488.57s before, 98.95s after -- 4.9x, about six and a half minutes saved per pull. Through the GUI a real pull now completes in 29s. Four levels were serial: - The five sources (arXiv, OpenReview, journals, Semantic Scholar, custom RSS) ran one after another, so wall clock was their sum -- journals alone took 91s while arXiv sat idle. They now run together, each still failing in isolation. - arXiv categories were fetched one per request in a loop: 13.13s for four against ~1.07s for one, now 0.73s (18x). The fetcher holds no shared mutable state and already degrades on 429. - Journals were fetched one publisher at a time (91.66s for twelve). - Within a single journal, every article's detail page was fetched serially, which dominated that journal's runtime (Science 48s, Science Advances 60s for ten papers). Now 25s and 45s. The remainder is the publishers' own latency: a single detail fetch has a ~5.1s median, so this is near the floor rather than something more workers would fix. Embedding batches were also serial: 120 uncached texts took 126.52s, now 34.54s (3.7x). The failure path is unchanged -- a raising request still falls back to hash embeddings for every missing index and flips provider/model, verified directly, because silently returning holes would corrupt scoring. Ordering is preserved everywhere by using executor.map rather than as_completed: downstream dedup keeps the first occurrence, so arXiv must still win over a later journal copy of the same paper. A test finishes work in reverse order to catch a regression back to completion-ordered collection. Concurrency is bounded and tunable rather than unlimited, since these are polite-use endpoints: PAPERFLOW_JOURNAL_CONCURRENCY (6, across publishers), PAPERFLOW_JOURNAL_DETAIL_CONCURRENCY (4, deliberately lower because it hits one host), PAPERFLOW_EMBED_CONCURRENCY (4, since 429s here would trip the hash fallback and quietly degrade recommendations), and the existing PAPERFLOW_MAX_CONCURRENCY for arXiv. Single-item work skips the pool entirely. Journal entry collection now gathers candidates before fetching details, so the per-journal limit is applied to accepted papers after _skip filtering, with a small headroom so concurrent detail fetching cannot leave a result short. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 09a90a6 commit 94bfd24

4 files changed

Lines changed: 383 additions & 68 deletions

File tree

‎deployments/feishu/daily-push-agent/main.py‎

Lines changed: 67 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from concurrent.futures import ThreadPoolExecutor, as_completed
1818
from datetime import datetime, timedelta, date
1919
from pathlib import Path
20-
from typing import Any, Callable, List, Dict, Optional, Set
20+
from typing import Any, Callable, List, Dict, Optional, Set, Tuple
2121
from dataclasses import dataclass
2222
from collections import defaultdict
2323

@@ -1203,9 +1203,9 @@ def fetch_arxiv_papers(fetch_days: int) -> List[Dict]:
12031203
return []
12041204
start_day = (today - timedelta(days=max(1, int(fetch_days or 1)) - 1)).date()
12051205
start_date = start_day.strftime("%Y%m%d")
1206-
papers: List[Dict] = []
12071206
per_category_limit = max(1, int(math.ceil(limit_per_source / max(1, len(arxiv_categories)))))
1208-
for category in arxiv_categories:
1207+
1208+
def fetch_one_category(category: str) -> List[Dict]:
12091209
print(f"Fetching from arXiv {category} ({start_date} to {end_date})...")
12101210
category_papers = arxiv_fetch_by_date(
12111211
start_date=start_date,
@@ -1227,6 +1227,20 @@ def fetch_arxiv_papers(fetch_days: int) -> List[Dict]:
12271227
paper for paper in category_papers
12281228
if _paper_date_in_window(paper, start_day, end_day)
12291229
]
1230+
return category_papers
1231+
1232+
papers: List[Dict] = []
1233+
# One independent API call per category; the serial loop this replaces
1234+
# measured 13.1s for 4 categories against ~1.1s for one. executor.map
1235+
# preserves category order so downstream dedup stays deterministic.
1236+
max_workers = min(_env_positive_int("PAPERFLOW_MAX_CONCURRENCY", default=5), len(arxiv_categories))
1237+
if len(arxiv_categories) > 1 and max_workers > 1:
1238+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
1239+
results = list(executor.map(fetch_one_category, arxiv_categories))
1240+
else:
1241+
results = [fetch_one_category(category) for category in arxiv_categories]
1242+
1243+
for category_papers in results:
12301244
papers.extend(category_papers)
12311245
if category_papers:
12321246
emit_fetched("arxiv", [{**paper, "source": "arxiv"} for paper in category_papers])
@@ -1309,54 +1323,62 @@ def fetch_cached_arxiv_papers() -> List[Dict]:
13091323
return []
13101324

13111325
all_papers = []
1312-
arxiv_papers = []
1313-
openreview_papers = []
1314-
journal_papers = []
1315-
semantic_papers = []
1316-
custom_rss_papers = []
1317-
1318-
# 1. 从 arXiv 获取论文
1319-
try:
1320-
arxiv_papers = fetch_arxiv_papers(days)
1321-
all_papers.extend(arxiv_papers)
1322-
except Exception as e:
1323-
print(f" arXiv fetch error: {e}")
13241326

1325-
# 2. 从 OpenReview 获取会议论文
1326-
try:
1327-
openreview_papers = fetch_openreview_papers(days)
1328-
all_papers.extend(openreview_papers)
1329-
except Exception as e:
1330-
print(f" OpenReview fetch error: {e}")
1327+
# The five sources are independent network calls, so running them one after
1328+
# another made the wall clock their sum -- journals alone measured 91s while
1329+
# arXiv sat idle. Each still fails in isolation: a raising source contributes
1330+
# nothing and the others complete.
1331+
def run_semantic_scholar() -> List[Dict]:
1332+
papers = fetch_semantic_scholar_papers(
1333+
semantic_queries or [], days, limit_per_source, target_date=target_date
1334+
)
1335+
if papers:
1336+
emit_fetched("semantic_scholar", papers)
1337+
emit({"phase": "source_complete", "source": "semantic_scholar", "count": len(papers)})
1338+
return papers
13311339

1332-
# 3. 从期刊获取论文
1333-
try:
1334-
journal_papers = fetch_journal_papers(days)
1335-
all_papers.extend(journal_papers)
1336-
except Exception as e:
1337-
print(f" Journal fetch error: {e}")
1340+
def run_custom_rss() -> List[Dict]:
1341+
papers = fetch_custom_rss_papers(custom_rss_urls or [], limit_per_source)
1342+
if papers:
1343+
emit_fetched("custom_rss", papers)
1344+
emit({"phase": "source_complete", "source": "custom_rss", "count": len(papers)})
1345+
return papers
13381346

1339-
# 4. Optional Semantic Scholar search, enabled from desktop settings.
1347+
source_jobs: List[Tuple[str, Callable[[], List[Dict]]]] = [
1348+
("arXiv", lambda: fetch_arxiv_papers(days)),
1349+
("OpenReview", lambda: fetch_openreview_papers(days)),
1350+
("Journal", lambda: fetch_journal_papers(days)),
1351+
]
13401352
if enable_semantic_scholar:
1341-
try:
1342-
semantic_papers = fetch_semantic_scholar_papers(semantic_queries or [], days, limit_per_source, target_date=target_date)
1343-
all_papers.extend(semantic_papers)
1344-
if semantic_papers:
1345-
emit_fetched("semantic_scholar", semantic_papers)
1346-
emit({"phase": "source_complete", "source": "semantic_scholar", "count": len(semantic_papers)})
1347-
except Exception as e:
1348-
print(f" Semantic Scholar fetch error: {e}")
1349-
1350-
# 5. Optional user-provided RSS/Atom feeds.
1353+
source_jobs.append(("Semantic Scholar", run_semantic_scholar))
13511354
if enable_custom_rss:
1355+
source_jobs.append(("Custom RSS", run_custom_rss))
1356+
1357+
def run_source(job: Tuple[str, Callable[[], List[Dict]]]) -> List[Dict]:
1358+
label, fetcher = job
13521359
try:
1353-
custom_rss_papers = fetch_custom_rss_papers(custom_rss_urls or [], limit_per_source)
1354-
all_papers.extend(custom_rss_papers)
1355-
if custom_rss_papers:
1356-
emit_fetched("custom_rss", custom_rss_papers)
1357-
emit({"phase": "source_complete", "source": "custom_rss", "count": len(custom_rss_papers)})
1358-
except Exception as e:
1359-
print(f" Custom RSS fetch error: {e}")
1360+
return fetcher() or []
1361+
except Exception as exc:
1362+
print(f" {label} fetch error: {exc}")
1363+
return []
1364+
1365+
if len(source_jobs) > 1:
1366+
with ThreadPoolExecutor(max_workers=len(source_jobs)) as executor:
1367+
source_results = list(executor.map(run_source, source_jobs))
1368+
else:
1369+
source_results = [run_source(job) for job in source_jobs]
1370+
1371+
# Preserve the original source ordering in all_papers: dedup downstream keeps
1372+
# the first occurrence, so arXiv must still win over a later journal copy.
1373+
by_label = dict(zip([label for label, _ in source_jobs], source_results))
1374+
# arXiv / OpenReview / journal results are consulted by the stale-fallback
1375+
# widening below, so they stay bound. Semantic Scholar and custom RSS have no
1376+
# such fallback and are already folded into all_papers.
1377+
arxiv_papers = by_label.get("arXiv", [])
1378+
openreview_papers = by_label.get("OpenReview", [])
1379+
journal_papers = by_label.get("Journal", [])
1380+
for papers in source_results:
1381+
all_papers.extend(papers)
13601382

13611383
if not all_papers:
13621384
all_papers.extend(fetch_cached_arxiv_papers())

‎skills/embedding/scripts/embed.py‎

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@
1414
import json
1515
import os
1616
import re
17+
from concurrent.futures import ThreadPoolExecutor
1718
from pathlib import Path
18-
from typing import Any, Dict, List, Optional
19+
from typing import Any, Dict, List, Optional, Tuple
1920

2021
import requests
2122

@@ -507,6 +508,23 @@ def embed_text(self, text: str) -> List[float]:
507508
self._save_cached(normalized_text, embedding)
508509
return embedding
509510

511+
def _batch_concurrency(self, batch_count: int) -> int:
512+
"""Concurrent embedding requests to issue.
513+
514+
Kept modest by default: these all hit one provider endpoint, and
515+
over-parallelising invites 429s that would trip the hash fallback and
516+
silently degrade recommendation quality. Override with
517+
PAPERFLOW_EMBED_CONCURRENCY.
518+
"""
519+
if batch_count <= 1:
520+
return 1
521+
raw = str(os.environ.get("PAPERFLOW_EMBED_CONCURRENCY", "")).strip()
522+
try:
523+
configured = int(raw) if raw else 4
524+
except ValueError:
525+
configured = 4
526+
return max(1, min(configured, batch_count, 8))
527+
510528
def embed_batch(self, texts: List[str], batch_size: int = 32) -> List[List[float]]:
511529
normalized_texts = [self._normalize_text(text) for text in texts]
512530
results: List[Optional[List[float]]] = [None] * len(normalized_texts)
@@ -523,16 +541,36 @@ def embed_batch(self, texts: List[str], batch_size: int = 32) -> List[List[float
523541
missing_indices.append(index)
524542

525543
if missing_indices and self.provider == "openai" and self.client is not None:
544+
batches = [
545+
missing_indices[batch_start:batch_start + batch_size]
546+
for batch_start in range(0, len(missing_indices), batch_size)
547+
]
548+
549+
def embed_one_batch(batch_indices: List[int]) -> List[Tuple[int, List[float]]]:
550+
batch_texts = [normalized_texts[index] for index in batch_indices]
551+
kwargs: Dict[str, Any] = {"model": self.model, "input": batch_texts}
552+
if self.model.startswith("text-embedding-3"):
553+
kwargs["dimensions"] = self.dimensions
554+
response = self.client.embeddings.create(**kwargs)
555+
return [
556+
(index, self._resize_vector(list(item.embedding)))
557+
for index, item in zip(batch_indices, response.data)
558+
]
559+
526560
try:
527-
for batch_start in range(0, len(missing_indices), batch_size):
528-
batch_indices = missing_indices[batch_start:batch_start + batch_size]
529-
batch_texts = [normalized_texts[index] for index in batch_indices]
530-
kwargs: Dict[str, Any] = {"model": self.model, "input": batch_texts}
531-
if self.model.startswith("text-embedding-3"):
532-
kwargs["dimensions"] = self.dimensions
533-
response = self.client.embeddings.create(**kwargs)
534-
for index, item in zip(batch_indices, response.data):
535-
embedding = self._resize_vector(list(item.embedding))
561+
# Batches are independent API round trips; issuing them serially
562+
# meant 120 uncached texts took 126.5s. The OpenAI client is
563+
# thread-safe and each cache entry is its own file, so the only
564+
# shared state is the pre-sized results list, written by index.
565+
workers = self._batch_concurrency(len(batches))
566+
if len(batches) > 1 and workers > 1:
567+
with ThreadPoolExecutor(max_workers=workers) as executor:
568+
batch_results = list(executor.map(embed_one_batch, batches))
569+
else:
570+
batch_results = [embed_one_batch(batch) for batch in batches]
571+
572+
for pairs in batch_results:
573+
for index, embedding in pairs:
536574
results[index] = embedding
537575
self._save_cached(normalized_texts[index], embedding)
538576
except Exception as exc:

‎skills/journal-fetcher/scripts/fetch_journal.py‎

Lines changed: 101 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
import requests
3333
import re
34+
from concurrent.futures import ThreadPoolExecutor
3435
from html import unescape
3536
from datetime import datetime, timedelta
3637
from typing import Dict, List, Optional, Any, Tuple
@@ -133,6 +134,11 @@
133134

134135
JOURNAL_REQUEST_TIMEOUT = float(os.environ.get("JOURNAL_REQUEST_TIMEOUT", "20"))
135136
JOURNAL_DETAIL_FETCH_ENABLED = os.environ.get("JOURNAL_DETAIL_FETCH_ENABLED", "1").strip().lower() not in {"0", "false", "off", "no"}
137+
# Extra RSS entries collected beyond `limit` so that candidates dropped by the
138+
# detail fetch (_skip: corrections, editorials, paywalled stubs) do not leave the
139+
# result short. Detail pages are now fetched concurrently, so the cost of a few
140+
# extra is small; unbounded collection would not be.
141+
JOURNAL_DETAIL_SKIP_HEADROOM = 5
136142
HTTP_RETRY_TOTAL = int(os.environ.get("PAPERFLOW_HTTP_RETRIES", "2"))
137143
HTTP_RETRY_BACKOFF = float(os.environ.get("PAPERFLOW_HTTP_BACKOFF", "0.8"))
138144
DEFAULT_REQUEST_HEADERS = {
@@ -1206,14 +1212,22 @@ def fetch_journal_papers(
12061212
return []
12071213

12081214
papers = []
1215+
# Entries are collected first, then their article pages are fetched
1216+
# concurrently below.
1217+
candidates: List[Dict[str, Any]] = []
12091218
cutoff_date = datetime.now() - timedelta(days=days)
12101219

12111220
try:
12121221
# 解析 RSS Feed
12131222
feed = feedparser.parse(journal_info["rss_url"])
12141223

12151224
for entry in feed.entries:
1216-
if len(papers) >= limit:
1225+
# Bound on collected candidates, not on accepted papers: detail
1226+
# fetching now happens after this loop. Some candidates are dropped
1227+
# later by _skip, so this can under-fill relative to the old
1228+
# behaviour; the extra headroom keeps the common case identical
1229+
# without fetching an unbounded number of article pages.
1230+
if len(candidates) >= limit + JOURNAL_DETAIL_SKIP_HEADROOM:
12171231
break
12181232

12191233
# 提取论文信息
@@ -1227,7 +1241,7 @@ def fetch_journal_papers(
12271241
if not _looks_like_research_article(entry, journal_key):
12281242
continue
12291243

1230-
paper = {
1244+
candidates.append({
12311245
"title": entry.get('title', 'Unknown'),
12321246
"abstract": _clean_abstract_text(entry.get('summary', entry.get('description', ''))),
12331247
"authors": extract_authors_from_rss(entry),
@@ -1238,12 +1252,33 @@ def fetch_journal_papers(
12381252
"publish_date": published.isoformat(),
12391253
"categories": [journal_key],
12401254
"source": "rss",
1241-
}
1242-
detail = _fetch_article_detail(paper["url"])
1255+
})
1256+
1257+
# Enrich from the article pages. This was one blocking request per paper,
1258+
# which dominated a single journal's runtime (Science measured 48s,
1259+
# Science Advances 60s for 10 papers). _http_get builds and closes its own
1260+
# session per call, so there is no shared state to guard. executor.map
1261+
# keeps feed order, and _skip filtering stays positional.
1262+
detail_workers = _journal_detail_concurrency(len(candidates))
1263+
if len(candidates) > 1 and detail_workers > 1:
1264+
def fetch_detail(candidate: Dict[str, Any]) -> Dict[str, Any]:
1265+
try:
1266+
return _fetch_article_detail(candidate["url"])
1267+
except Exception as exc:
1268+
print(f" Detail fetch error for {candidate.get('url')}: {exc}")
1269+
return {}
1270+
1271+
with ThreadPoolExecutor(max_workers=detail_workers) as executor:
1272+
details = list(executor.map(fetch_detail, candidates))
1273+
else:
1274+
details = [_fetch_article_detail(candidate["url"]) for candidate in candidates]
1275+
1276+
for candidate, detail in zip(candidates, details):
12431277
if detail.get("_skip"):
12441278
continue
1245-
paper = _merge_detail_into_paper(paper, detail)
1246-
papers.append(paper)
1279+
papers.append(_merge_detail_into_paper(candidate, detail))
1280+
if len(papers) >= limit:
1281+
break
12471282

12481283
except Exception as e:
12491284
print(f"Error fetching {journal_info['name']}: {e}")
@@ -1350,6 +1385,40 @@ def fetch_by_date(
13501385
return unique_papers[:limit]
13511386

13521387

1388+
def _journal_detail_concurrency(candidate_count: int) -> int:
1389+
"""Workers for per-article detail fetches within a single journal.
1390+
1391+
Unlike the journal-level pool these all hit the *same* publisher host, so the
1392+
cap is deliberately low. Override with PAPERFLOW_JOURNAL_DETAIL_CONCURRENCY.
1393+
"""
1394+
if candidate_count <= 1:
1395+
return 1
1396+
raw = str(os.environ.get("PAPERFLOW_JOURNAL_DETAIL_CONCURRENCY", "")).strip()
1397+
try:
1398+
configured = int(raw) if raw else 4
1399+
except ValueError:
1400+
configured = 4
1401+
return max(1, min(configured, candidate_count, 8))
1402+
1403+
1404+
def _journal_fetch_concurrency(source_count: int) -> int:
1405+
"""Workers to use when fetching journals in parallel.
1406+
1407+
Each journal is a different publisher host, so this is not hammering a single
1408+
server. Capped conservatively anyway (and overridable via
1409+
PAPERFLOW_JOURNAL_CONCURRENCY) because these are polite-use RSS endpoints and
1410+
some publishers rate-limit aggressively.
1411+
"""
1412+
if source_count <= 1:
1413+
return 1
1414+
raw = str(os.environ.get("PAPERFLOW_JOURNAL_CONCURRENCY", "")).strip()
1415+
try:
1416+
configured = int(raw) if raw else 6
1417+
except ValueError:
1418+
configured = 6
1419+
return max(1, min(configured, source_count, 12))
1420+
1421+
13531422
def get_recent_papers(
13541423
days: int = 7,
13551424
journals: List[str] = None,
@@ -1379,13 +1448,32 @@ def get_recent_papers(
13791448

13801449
all_papers = []
13811450

1382-
for journal in normalized_journals:
1383-
papers = fetch_journal_papers(
1384-
journal=journal,
1385-
limit=limit_per_journal,
1386-
days=days
1387-
)
1388-
all_papers.extend(papers)
1451+
# Each journal is an independent RSS fetch plus optional detail-page
1452+
# enrichment, so the serial loop this replaces spent its time waiting: 12
1453+
# journals measured 91.66s end to end. executor.map preserves input order,
1454+
# which keeps the dedup below deterministic -- the first journal listed still
1455+
# wins for a paper that appears in several feeds.
1456+
max_workers = _journal_fetch_concurrency(len(normalized_journals))
1457+
if len(normalized_journals) > 1 and max_workers > 1:
1458+
def fetch_one(journal: str) -> List[Dict[str, Any]]:
1459+
try:
1460+
return fetch_journal_papers(journal=journal, limit=limit_per_journal, days=days)
1461+
except Exception as exc:
1462+
# One unreachable feed must not abort the whole pull.
1463+
print(f" Journal fetch error for {journal}: {exc}")
1464+
return []
1465+
1466+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
1467+
for papers in executor.map(fetch_one, normalized_journals):
1468+
all_papers.extend(papers)
1469+
else:
1470+
for journal in normalized_journals:
1471+
papers = fetch_journal_papers(
1472+
journal=journal,
1473+
limit=limit_per_journal,
1474+
days=days
1475+
)
1476+
all_papers.extend(papers)
13891477

13901478
# 去重
13911479
seen = set()

0 commit comments

Comments
 (0)